diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c590b96e..e72544d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,24 @@ jobs: name: Backend runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres + steps: - uses: actions/checkout@v6 diff --git a/backend/migrations/0034_carddav_incremental_sync.sql b/backend/migrations/0034_carddav_incremental_sync.sql new file mode 100644 index 00000000..5e4e07e7 --- /dev/null +++ b/backend/migrations/0034_carddav_incremental_sync.sql @@ -0,0 +1,76 @@ +-- Remote CardDAV collection state is separate from the sync token and ETags +-- Mailflow serves to its own CardDAV clients. +DO $$ +BEGIN + IF EXISTS ( + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY user_id, external_url + ORDER BY created_at, id + ) AS position + FROM address_books + WHERE source = 'carddav' AND external_url IS NOT NULL + ) + SELECT 1 + FROM ranked + JOIN contacts contact ON contact.address_book_id = ranked.id + WHERE ranked.position > 1 + ) THEN + RAISE EXCEPTION 'cannot consolidate populated duplicate CardDAV address books'; + END IF; +END $$; + +ALTER TABLE address_books + ADD COLUMN IF NOT EXISTS remote_sync_token TEXT, + ADD COLUMN IF NOT EXISTS remote_sync_capability TEXT NOT NULL DEFAULT 'unknown' + CHECK (remote_sync_capability IN ('unknown', 'sync-collection', 'snapshot')), + ADD COLUMN IF NOT EXISTS remote_sync_revision BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS remote_projection_fingerprint TEXT; + +CREATE TABLE IF NOT EXISTS carddav_remote_objects ( + address_book_id UUID NOT NULL REFERENCES address_books(id) ON DELETE CASCADE, + href TEXT NOT NULL, + remote_etag TEXT, + vcard TEXT NOT NULL, + primary_email TEXT, + disposition TEXT NOT NULL CHECK (disposition IN ('separate', 'merge', 'skip')), + local_contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL, + merge_before JSONB, + merge_applied JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (address_book_id, href) +); + +UPDATE user_integrations +SET config = config || jsonb_build_object('connectionGeneration', gen_random_uuid()::text) +WHERE provider = 'carddav' + AND NOT (config ? 'connectionGeneration'); + +WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY user_id, external_url + ORDER BY created_at, id + ) AS position + FROM address_books + WHERE source = 'carddav' AND external_url IS NOT NULL +) +DELETE FROM address_books +WHERE id IN (SELECT id FROM ranked WHERE position > 1); + +CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_remote_book_idx + ON address_books (user_id, external_url) + WHERE source = 'carddav' AND external_url IS NOT NULL; + +CREATE INDEX IF NOT EXISTS carddav_remote_object_contact_idx + ON carddav_remote_objects (local_contact_id) + WHERE local_contact_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS carddav_remote_object_email_idx + ON carddav_remote_objects (address_book_id, primary_email); + +CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_merge_source_per_contact_idx + ON carddav_remote_objects (local_contact_id) + WHERE disposition = 'merge' AND local_contact_id IS NOT NULL; diff --git a/backend/migrations/0035_carddav_bidirectional_sync.sql b/backend/migrations/0035_carddav_bidirectional_sync.sql new file mode 100644 index 00000000..38258c2f --- /dev/null +++ b/backend/migrations/0035_carddav_bidirectional_sync.sql @@ -0,0 +1,76 @@ +ALTER TABLE address_books + ADD COLUMN IF NOT EXISTS remote_create_capability TEXT NOT NULL DEFAULT 'unknown' + CHECK (remote_create_capability IN ('unknown', 'allowed', 'denied')), + ADD COLUMN IF NOT EXISTS remote_update_capability TEXT NOT NULL DEFAULT 'unknown' + CHECK (remote_update_capability IN ('unknown', 'allowed', 'denied')), + ADD COLUMN IF NOT EXISTS remote_delete_capability TEXT NOT NULL DEFAULT 'unknown' + CHECK (remote_delete_capability IN ('unknown', 'allowed', 'denied')); + +ALTER TABLE contacts + ADD COLUMN IF NOT EXISTS additional_fields JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE carddav_remote_objects + ALTER COLUMN disposition SET DEFAULT 'separate', + ADD COLUMN IF NOT EXISTS mapping_status TEXT NOT NULL DEFAULT 'pending_materialization' + CHECK (mapping_status IN ('pending_materialization', 'synced', 'pending_push', 'conflict')), + ADD COLUMN IF NOT EXISTS vcard_version TEXT + CHECK (vcard_version IS NULL OR vcard_version IN ('3.0', '4.0')), + ADD COLUMN IF NOT EXISTS remote_semantic_hash TEXT, + ADD COLUMN IF NOT EXISTS local_contact_hash TEXT, + ADD COLUMN IF NOT EXISTS mapping_revision BIGINT NOT NULL DEFAULT 0 + CHECK (mapping_revision >= 0), + ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS last_push_error_code TEXT, + ADD COLUMN IF NOT EXISTS last_push_error_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS legacy_projection JSONB; + +UPDATE carddav_remote_objects +SET legacy_projection = jsonb_build_object( + 'disposition', disposition, + 'merge_before', merge_before, + 'merge_applied', merge_applied +); + +CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_active_mapping_per_contact_idx + ON carddav_remote_objects (local_contact_id) + WHERE local_contact_id IS NOT NULL + AND mapping_status <> 'pending_materialization'; + +CREATE TABLE IF NOT EXISTS carddav_conflicts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + address_book_id UUID NOT NULL, + href TEXT NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + base_local_hash TEXT, + remote_etag TEXT, + local_vcard TEXT, + remote_vcard TEXT, + local_tombstone BOOLEAN NOT NULL DEFAULT false, + remote_tombstone BOOLEAN NOT NULL DEFAULT false, + status TEXT NOT NULL DEFAULT 'unresolved' + CHECK (status IN ('unresolved', 'resolved')), + resolution TEXT + CHECK (resolution IS NULL OR resolution IN ('keep-mailflow', 'keep-carddav')), + resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT carddav_conflicts_remote_object_fkey FOREIGN KEY (address_book_id, href) + REFERENCES carddav_remote_objects(address_book_id, href) ON DELETE CASCADE, + CHECK (local_tombstone OR local_vcard IS NOT NULL), + CHECK (remote_tombstone OR remote_vcard IS NOT NULL), + CHECK ( + (status = 'unresolved' + AND resolution IS NULL + AND resolved_by IS NULL + AND resolved_at IS NULL) + OR + (status = 'resolved' + AND resolution IS NOT NULL + AND resolved_at IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS carddav_one_unresolved_conflict_per_mapping_idx + ON carddav_conflicts (address_book_id, href) + WHERE status = 'unresolved'; diff --git a/backend/migrations/0036_carddav_bidirectional_cleanup.sql b/backend/migrations/0036_carddav_bidirectional_cleanup.sql new file mode 100644 index 00000000..6d253e07 --- /dev/null +++ b/backend/migrations/0036_carddav_bidirectional_cleanup.sql @@ -0,0 +1,42 @@ +UPDATE user_integrations +SET config = config - 'dupMode' +WHERE provider = 'carddav' AND config ? 'dupMode'; + +DROP INDEX IF EXISTS carddav_one_merge_source_per_contact_idx; + +ALTER TABLE carddav_remote_objects + DROP COLUMN disposition, + DROP COLUMN merge_before, + DROP COLUMN merge_applied, + DROP COLUMN legacy_projection, + ADD COLUMN IF NOT EXISTS pending_operation TEXT + CHECK (pending_operation IS NULL OR pending_operation IN ('update', 'delete')), + ADD COLUMN IF NOT EXISTS pending_vcard TEXT, + ADD COLUMN IF NOT EXISTS pending_local_hash TEXT, + ADD COLUMN IF NOT EXISTS pending_remote_semantic_hash TEXT, + ADD COLUMN IF NOT EXISTS pending_started_at TIMESTAMPTZ, + ADD CHECK ( + ( + pending_operation IS NULL + AND pending_vcard IS NULL + AND pending_local_hash IS NULL + AND pending_remote_semantic_hash IS NULL + AND pending_started_at IS NULL + ) + OR + ( + pending_operation = 'update' + AND pending_vcard IS NOT NULL + AND pending_local_hash IS NOT NULL + AND pending_remote_semantic_hash IS NOT NULL + AND pending_started_at IS NOT NULL + ) + OR + ( + pending_operation = 'delete' + AND pending_vcard IS NULL + AND pending_local_hash IS NOT NULL + AND pending_remote_semantic_hash IS NULL + AND pending_started_at IS NOT NULL + ) + ); diff --git a/backend/migrations/0037_carddav_conflict_retention.sql b/backend/migrations/0037_carddav_conflict_retention.sql new file mode 100644 index 00000000..7714b952 --- /dev/null +++ b/backend/migrations/0037_carddav_conflict_retention.sql @@ -0,0 +1,6 @@ +ALTER TABLE carddav_conflicts + DROP CONSTRAINT carddav_conflicts_remote_object_fkey; + +ALTER TABLE carddav_conflicts + ADD CONSTRAINT carddav_conflicts_address_book_id_fkey + FOREIGN KEY (address_book_id) REFERENCES address_books(id) ON DELETE CASCADE; diff --git a/backend/package.json b/backend/package.json index fc54c5cf..53bcb5cd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,9 @@ "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js", - "test": "vitest run", + "test": "npm run test:unit && npm run test:db", + "test:unit": "vitest run --exclude='**/*.db.test.js' --exclude='**/*.integration.test.js'", + "test:db": "vitest run src/**/*.db.test.js src/**/*.integration.test.js", "test:watch": "vitest", "lint": "eslint src --max-warnings 0" }, diff --git a/backend/src/index.js b/backend/src/index.js index 03691887..48751412 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -29,6 +29,7 @@ import categoriesRoutes from './routes/categories.js'; import gtdRoutes from './routes/gtd.js'; import carddavRouter from './routes/carddav.js'; import carddavAccountRouter from './routes/carddavAccount.js'; +import carddavConflictsRouter from './routes/carddavConflicts.js'; import { startCardavScheduler } from './services/carddavSync.js'; import { encryptExistingCredentials, query } from './services/db.js'; import { runMigrations } from './services/migrations.js'; @@ -159,6 +160,7 @@ app.use('/api/rules', rulesRoutes); app.use('/api/block-list', blockListRoutes); app.use('/api/contacts', contactsRoutes); app.use('/api/todoist', todoistRoutes); +app.use('/api/carddav/conflicts', carddavConflictsRouter); app.use('/api/carddav', carddavAccountRouter); app.use('/api', aiRoutes); app.use('/api', categoriesRoutes); diff --git a/backend/src/routes/carddav.js b/backend/src/routes/carddav.js index 231383d5..c03f44b0 100644 --- a/backend/src/routes/carddav.js +++ b/backend/src/routes/carddav.js @@ -11,12 +11,30 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; -import crypto from 'crypto'; import { query } from '../services/db.js'; -import { parseVCard } from '../utils/vcard.js'; import { authLimiterConfig } from '../services/authLimiter.js'; import { consume as rlConsume } from '../services/rateLimiter.js'; import { logAuthEvent } from '../services/authEvents.js'; +import { xmlEscape } from '../services/carddavXml.js'; +import { + CARDDAV_CONTACT_ERROR_STATUS, + createContactFromVCard, + deleteContactFromVCard, + replaceContactFromVCard, +} from '../services/carddavContactService.js'; +import { presentedEtag, presentedVCard } from '../utils/vcardProperties.js'; + +// Select the modeled columns + the retained remote vCard so a mapped contact can be +// served losslessly (see presentedVCard). local_contact_id maps at most one active +// remote object per contact, so this stays one row per contact. +const CONTACT_READ_COLUMNS = ` + c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones, + c.organization, c.notes, c.photo_data, c.additional_fields, + c.vcard, c.etag, mapping.vcard AS mapping_vcard`; +const CONTACT_MAPPING_JOIN = ` + LEFT JOIN carddav_remote_objects mapping + ON mapping.local_contact_id = c.id + AND mapping.mapping_status <> 'pending_materialization'`; const router = Router(); @@ -37,6 +55,7 @@ setInterval(() => { // CardDAV clients can issue dozens of requests per sync (PROPFIND + per-card GET/PUT). // Use a generous per-IP ceiling independent of the login rate-limit config. const CARDDAV_MAX_REQUESTS = 500; +const CARDDAV_MAX_BODY_BYTES = 1024 * 1024; function cardavRateLimit(req, res, next) { const { windowMs } = authLimiterConfig; @@ -114,6 +133,10 @@ async function cardavAuth(req, res, next) { router.use(cardavRateLimit); router.use(cardavAuth); +router.param('userId', (req, res, next, userId) => { + if (userId !== req.cardavUserId) return res.status(403).end(); + next(); +}); // ── XML helpers ─────────────────────────────────────────────────────────────── @@ -154,35 +177,161 @@ function propstat(props, status) { ].join(''); } -function xmlEscape(s) { - return String(s || '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - function sendXml(res, status, xml) { res.status(status) .setHeader('Content-Type', 'application/xml; charset=utf-8') .send(xml); } -// Collect the request body as a string by reading the raw stream. +function bodyTooLargeError() { + return Object.assign(new Error('CardDAV request body exceeds 1 MiB'), { + code: 'ERR_CARDDAV_BODY_TOO_LARGE', + }); +} + +function requestAbortedError() { + return Object.assign(new Error('CardDAV request body ended prematurely'), { + code: 'ERR_CARDDAV_REQUEST_ABORTED', + }); +} + +// Collect the request body as bytes before converting it to UTF-8. // We do not go through express.json/text — CardDAV uses custom content types. -function rawBody(req) { +export function readRawBody(req, { maxBytes = Infinity } = {}) { return new Promise((resolve, reject) => { // If a body parser already collected it (unlikely here), use it. - if (typeof req.body === 'string') return resolve(req.body); - if (Buffer.isBuffer(req.body)) return resolve(req.body.toString('utf8')); - let data = ''; - req.setEncoding('utf8'); - req.on('data', chunk => { data += chunk; }); - req.on('end', () => resolve(data)); - req.on('error', reject); + if (typeof req.body === 'string') { + return Buffer.byteLength(req.body, 'utf8') > maxBytes + ? reject(bodyTooLargeError()) + : resolve(req.body); + } + if (Buffer.isBuffer(req.body)) { + return req.body.length > maxBytes + ? reject(bodyTooLargeError()) + : resolve(req.body.toString('utf8')); + } + + const chunks = []; + let total = 0; + let state = 'collecting'; + const cleanup = () => { + req.removeListener('data', onData); + req.removeListener('end', onEnd); + req.removeListener('error', onError); + req.removeListener('close', onClose); + req.removeListener('aborted', onAborted); + }; + const rejectAndDrain = (error, resume) => { + if (state !== 'collecting') return; + state = 'draining'; + req.removeListener('data', onData); + req.removeListener('aborted', onAborted); + reject(error); + if (resume) req.resume(); + }; + const onData = chunk => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (total + buffer.length > maxBytes) { + rejectAndDrain(bodyTooLargeError(), true); + return; + } + total += buffer.length; + chunks.push(buffer); + }; + const onEnd = () => { + const shouldResolve = state === 'collecting'; + state = 'settled'; + cleanup(); + if (shouldResolve) resolve(Buffer.concat(chunks, total).toString('utf8')); + }; + const onError = error => { + if (state === 'draining') return; + state = 'settled'; + cleanup(); + reject(error); + }; + const onClose = () => { + const shouldReject = state === 'collecting'; + state = 'settled'; + cleanup(); + if (shouldReject) reject(requestAbortedError()); + }; + const onAborted = () => rejectAndDrain(requestAbortedError(), false); + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + req.on('close', onClose); + req.on('aborted', onAborted); }); } +function ifMatchEtag(req) { + const value = req.headers['if-match']; + return value && value !== '*' ? value.replace(/^"(.*)"$/, '$1') : null; +} + +function requiresAbsentResource(req) { + return String(req.headers['if-none-match'] || '').trim() === '*'; +} + +// Read a served contact (modeled columns + retained mapping vCard) so the route can +// serve, and validate preconditions against, the presented representation. +async function readServedContact(bookId, userId, uid) { + const { rows: [contact] } = await query( + `SELECT ${CONTACT_READ_COLUMNS} + FROM contacts c + JOIN address_books ab ON ab.id = c.address_book_id + ${CONTACT_MAPPING_JOIN} + WHERE ab.id = $1 AND ab.user_id = $2 AND c.uid = $3`, + [bookId, userId, uid], + ); + return contact || null; +} + +// The strong validator MailFlow's CardDAV server exposes, derived from the presented +// document, so GET/REPORT/PROPFIND all quote the same ETag and mutations +// validate against it. +function servedEtag(contact) { + return `"${presentedEtag(contact)}"`; +} + +// A mapped contact (retained upstream document present) — its mutations delegate to the +// shared external write path and must be conditional. +function isMappedContact(contact) { + return Boolean(contact?.mapping_vcard); +} + +// RFC 7232 §3.1/§3.2 + RFC 6352 §6.3.2 preconditions against the SERVED ETag, +// centralized so every mutation path fences on the same validator GET exposes. Returns +// the HTTP status to send, or null to proceed. +function preconditionFailure(req, contact) { + if (requiresAbsentResource(req)) return contact ? 412 : null; + if (!contact) return null; + const clientEtag = ifMatchEtag(req); + if (isMappedContact(contact)) { + // A mapped mutation REQUIRES a REAL strong validator. ifMatchEtag returns + // null for absent, `*`, and empty If-Match — all of which lack freshness and would let + // a client authoritatively overwrite the upstream document — so 428 (retry with a real + // If-Match). A real ETag is compared strongly (412 on mismatch). + if (!clientEtag) return 428; + return clientEtag !== presentedEtag(contact) ? 412 : null; + } + return clientEtag && clientEtag !== presentedEtag(contact) ? 412 : null; +} + +function carddavMutationError(res, err, operation) { + const status = { + ...CARDDAV_CONTACT_ERROR_STATUS, + ERR_CARDDAV_BODY_TOO_LARGE: 413, + ERR_CARDDAV_REQUEST_ABORTED: 400, + ERR_LOCAL_ETAG_MISMATCH: 412, + ERR_LOCAL_PRECONDITION_FAILED: 412, + }[err.code] ?? (Number.isInteger(err.status) ? err.status : null); + if (status) return res.status(status).end(); + console.error(`CardDAV ${operation} error:`, err); + return res.status(500).end(); +} + // ── OPTIONS (broadcast CardDAV support) ────────────────────────────────────── router.options('*', (req, res) => { @@ -213,7 +362,6 @@ router.propfind('/', async (req, res) => { router.propfind('/:userId/', async (req, res) => { const userId = req.cardavUserId; - if (req.params.userId !== userId) return res.status(403).end(); const principalPath = `/carddav/${userId}/`; @@ -242,7 +390,6 @@ router.propfind('/:userId/', async (req, res) => { router.propfind('/:userId/:bookId/', async (req, res) => { const userId = req.cardavUserId; - if (req.params.userId !== userId) return res.status(403).end(); const depth = req.headers['depth'] || '0'; @@ -268,9 +415,11 @@ router.propfind('/:userId/:bookId/', async (req, res) => { return sendXml(res, 207, multistatus([bookResponse])); } - // Depth: 1 — list all VCards in the book. + // Depth: 1 — list all VCards in the book. Quote the PRESENTED ETag so + // PROPFIND, REPORT, and GET all expose the same strong validator. const contacts = await query( - 'SELECT uid, etag FROM contacts WHERE address_book_id = $1', + `SELECT ${CONTACT_READ_COLUMNS} FROM contacts c ${CONTACT_MAPPING_JOIN} + WHERE c.address_book_id = $1`, [book.id] ); @@ -278,7 +427,7 @@ router.propfind('/:userId/:bookId/', async (req, res) => { response(`${bookPath}${encodeURIComponent(c.uid)}.vcf`, [ propstat([ '', - `"${xmlEscape(c.etag)}"`, + `${servedEtag(c)}`, 'text/vcard;charset=utf-8', ], '200 OK'), ]) @@ -291,7 +440,6 @@ router.propfind('/:userId/:bookId/', async (req, res) => { router.report('/:userId/:bookId/', async (req, res) => { const userId = req.cardavUserId; - if (req.params.userId !== userId) return res.status(403).end(); const bookResult = await query( 'SELECT * FROM address_books WHERE id = $1 AND user_id = $2', @@ -301,12 +449,22 @@ router.report('/:userId/:bookId/', async (req, res) => { const book = bookResult.rows[0]; const bookPath = `/carddav/${userId}/${book.id}/`; - const body = await rawBody(req); + // Bound the REPORT body like PUT — the handler only tests it for a marker + // string, so an unbounded body would be a memory-exhaustion vector (413). + let body; + try { + body = await readRawBody(req, { maxBytes: CARDDAV_MAX_BODY_BYTES }); + } catch (err) { + return carddavMutationError(res, err, 'REPORT'); + } const isSyncCollection = body.includes('sync-collection'); - // Fetch all contacts with their vCard data. + // Fetch all contacts with their vCard data (retained remote vCard included so a + // mapped contact is served losslessly). const contacts = await query( - 'SELECT uid, vcard, etag FROM contacts WHERE address_book_id = $1', + `SELECT ${CONTACT_READ_COLUMNS} + FROM contacts c ${CONTACT_MAPPING_JOIN} + WHERE c.address_book_id = $1`, [book.id] ); @@ -315,9 +473,9 @@ router.report('/:userId/:bookId/', async (req, res) => { return response(href, [ propstat([ '', - `"${xmlEscape(c.etag)}"`, + `${servedEtag(c)}`, 'text/vcard;charset=utf-8', - `${xmlEscape(c.vcard || '')}`, + `${xmlEscape(presentedVCard(c) || '')}`, ], '200 OK'), ]); }); @@ -340,42 +498,28 @@ router.report('/:userId/:bookId/', async (req, res) => { router.get('/:userId/:bookId/:filename', async (req, res) => { const userId = req.cardavUserId; - if (req.params.userId !== userId) return res.status(403).end(); const uid = req.params.filename.replace(/\.vcf$/i, ''); - const result = await query( - `SELECT c.vcard, c.etag FROM contacts c - JOIN address_books ab ON ab.id = c.address_book_id - WHERE ab.id = $1 AND ab.user_id = $2 AND c.uid = $3`, - [req.params.bookId, userId, uid] - ); - if (!result.rows.length) return res.status(404).end(); + const contact = await readServedContact(req.params.bookId, userId, uid); + if (!contact) return res.status(404).end(); - const { vcard, etag } = result.rows[0]; res.set({ 'Content-Type': 'text/vcard;charset=utf-8', - 'ETag': `"${etag}"`, - }).send(vcard); + 'ETag': servedEtag(contact), + }).send(presentedVCard(contact)); }); // ── PUT /{userId}/{bookId}/{uid}.vcf (create or update) ────────────────────── router.put('/:userId/:bookId/:filename', async (req, res) => { const userId = req.cardavUserId; - if (req.params.userId !== userId) return res.status(403).end(); const uid = req.params.filename.replace(/\.vcf$/i, ''); - const body = await rawBody(req); - if (!body.trim()) return res.status(400).end(); - - const parsed = parseVCard(body); - const vcard = body; // store what the client sent verbatim - const etag = crypto.createHash('md5').update(vcard).digest('hex'); - - const primaryEmail = parsed.emails[0]?.value?.toLowerCase() || null; try { + const body = await readRawBody(req, { maxBytes: CARDDAV_MAX_BODY_BYTES }); + if (!body.trim()) return res.status(400).end(); const bookResult = await query( 'SELECT id FROM address_books WHERE id = $1 AND user_id = $2', [req.params.bookId, userId] @@ -383,65 +527,33 @@ router.put('/:userId/:bookId/:filename', async (req, res) => { if (!bookResult.rows.length) return res.status(404).end(); const bookId = bookResult.rows[0].id; - const existing = await query( - 'SELECT id, etag FROM contacts WHERE address_book_id = $1 AND uid = $2', - [bookId, uid] - ); - - if (existing.rows.length) { - // Enforce If-Match precondition (RFC 6352 §6.3.2) - const ifMatch = req.headers['if-match']; - if (ifMatch && ifMatch !== '*') { - const clientEtag = ifMatch.replace(/^"(.*)"$/, '$1'); - if (clientEtag !== existing.rows[0].etag) return res.status(412).end(); - } - // Update - await query(` - UPDATE contacts SET - vcard = $1, etag = $2, - display_name = $3, first_name = $4, last_name = $5, - primary_email = $6, emails = $7, phones = $8, - organization = $9, notes = $10, photo_data = $11, - is_auto = false, updated_at = NOW() - WHERE id = $12 - `, [ - vcard, etag, - parsed.displayName, parsed.firstName, parsed.lastName, - primaryEmail, - JSON.stringify(parsed.emails), JSON.stringify(parsed.phones), - parsed.organization, parsed.notes, parsed.photoData, - existing.rows[0].id, - ]); - await query( - 'UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1', - [bookId] - ); - res.set('ETag', `"${etag}"`).status(204).end(); + const existing = await readServedContact(bookId, userId, uid); + const failure = preconditionFailure(req, existing); + if (failure) return res.status(failure).end(); + + if (existing) { + await replaceContactFromVCard(userId, { + localAddressBookId: bookId, + uid, + rawVCard: body, + expectedLocalEtag: existing.etag, + }); + const served = await readServedContact(bookId, userId, uid); + if (served) res.set('ETag', servedEtag(served)); + res.status(204).end(); } else { - // Create - await query(` - INSERT INTO contacts ( - address_book_id, user_id, uid, vcard, etag, - display_name, first_name, last_name, primary_email, - emails, phones, organization, notes, photo_data, is_auto - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, false) - `, [ - bookId, userId, uid, vcard, etag, - parsed.displayName, parsed.firstName, parsed.lastName, - primaryEmail, - JSON.stringify(parsed.emails), JSON.stringify(parsed.phones), - parsed.organization, parsed.notes, parsed.photoData, - ]); - await query( - 'UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1', - [bookId] - ); - res.set('ETag', `"${etag}"`).status(201).end(); + await createContactFromVCard(userId, { + localAddressBookId: bookId, + uid, + rawVCard: body, + ...(requiresAbsentResource(req) ? { expectedAbsent: true } : {}), + }); + const served = await readServedContact(bookId, userId, uid); + if (served) res.set('ETag', servedEtag(served)); + res.status(201).end(); } } catch (err) { - if (err.code === '23505') return res.status(409).end(); // unique conflict - console.error('CardDAV PUT error:', err); - res.status(500).end(); + carddavMutationError(res, err, 'PUT'); } }); @@ -449,30 +561,28 @@ router.put('/:userId/:bookId/:filename', async (req, res) => { router.delete('/:userId/:bookId/:filename', async (req, res) => { const userId = req.cardavUserId; - if (req.params.userId !== userId) return res.status(403).end(); const uid = req.params.filename.replace(/\.vcf$/i, ''); try { - const result = await query( - `DELETE FROM contacts - USING address_books - WHERE contacts.address_book_id = address_books.id - AND address_books.id = $1 - AND address_books.user_id = $2 - AND contacts.uid = $3 - RETURNING address_books.id AS book_id`, - [req.params.bookId, userId, uid] - ); - if (!result.rows.length) return res.status(404).end(); - await query( - 'UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1', - [result.rows[0].book_id] + const bookResult = await query( + 'SELECT id FROM address_books WHERE id = $1 AND user_id = $2', + [req.params.bookId, userId] ); + if (!bookResult.rows.length) return res.status(404).end(); + const bookId = bookResult.rows[0].id; + const existing = await readServedContact(bookId, userId, uid); + if (!existing) return res.status(404).end(); + const failure = preconditionFailure(req, existing); + if (failure) return res.status(failure).end(); + await deleteContactFromVCard(userId, { + localAddressBookId: bookId, + uid, + expectedLocalEtag: existing.etag, + }); res.status(204).end(); } catch (err) { - console.error('CardDAV DELETE error:', err); - res.status(500).end(); + carddavMutationError(res, err, 'DELETE'); } }); diff --git a/backend/src/routes/carddav.test.js b/backend/src/routes/carddav.test.js new file mode 100644 index 00000000..3484bdf0 --- /dev/null +++ b/backend/src/routes/carddav.test.js @@ -0,0 +1,704 @@ +import { EventEmitter, once } from 'node:events'; +import { Readable } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + createContactFromVCard: vi.fn(), + replaceContactFromVCard: vi.fn(), + deleteContactFromVCard: vi.fn(), +})); + +vi.mock('bcryptjs', () => ({ + default: { + hashSync: vi.fn(() => 'hash'), + compare: vi.fn(), + }, +})); +vi.mock('../services/db.js', () => ({ query: mocks.query })); +vi.mock('../services/authLimiter.js', () => ({ + authLimiterConfig: { maxRequests: 5, windowMs: 60_000 }, +})); +vi.mock('../services/rateLimiter.js', () => ({ consume: vi.fn() })); +vi.mock('../services/authEvents.js', () => ({ logAuthEvent: vi.fn() })); +vi.mock('../services/carddavContactService.js', () => ({ + CARDDAV_CONTACT_ERROR_STATUS: { + ERR_CONTACT_VALIDATION: 400, + ERR_CONTACT_UID_MISMATCH: 400, + ERR_CONTACT_NOT_FOUND: 404, + ERR_ADDRESS_BOOK_NOT_FOUND: 404, + ERR_CONTACT_EXISTS: 409, + ERR_CARDDAV_CONFLICT: 409, + ERR_CARDDAV_FINAL_FENCE: 503, + ERR_CARDDAV_STALE_GENERATION: 503, + ERR_CARDDAV_AMBIGUOUS_WRITE: 409, + ERR_CARDDAV_PENDING_INTENT: 409, + ERR_CARDDAV_READ_ONLY: 403, + '23505': 409, + }, + createContactFromVCard: mocks.createContactFromVCard, + replaceContactFromVCard: mocks.replaceContactFromVCard, + deleteContactFromVCard: mocks.deleteContactFromVCard, +})); + +const { presentedEtag } = await import('../utils/vcardProperties.js'); +const { default: router, readRawBody } = await import('./carddav.js'); + +// A served contact row as readServedContact returns it (modeled columns + retained +// mapping vCard). The served ETag is derived from the presented document. +const SERVED_VCARD = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD\r\n'; +function servedContactRow(overrides = {}) { + return { + id: 'contact-1', uid: 'contact-uid', etag: 'local-etag', vcard: SERVED_VCARD, + mapping_vcard: null, display_name: 'Confirmed', first_name: null, last_name: null, + emails: [], phones: [], organization: null, notes: null, photo_data: null, + additional_fields: [], ...overrides, + }; +} +const PRESENTED_ETAG = presentedEtag(servedContactRow()); +const SERVED_ETAG = `"${PRESENTED_ETAG}"`; + +function handler(method, path) { + return router.stack + .find(layer => layer.route?.path === path && layer.route.methods[method]) + .route.stack.at(-1).handle; +} + +const reportHandler = handler('report', '/:userId/:bookId/'); +const getHandler = handler('get', '/:userId/:bookId/:filename'); +const putHandler = handler('put', '/:userId/:bookId/:filename'); +const deleteHandler = handler('delete', '/:userId/:bookId/:filename'); + +describe('userId route parameter', () => { + it('rejects a userId that differs from the authenticated CardDAV user', () => { + const guard = router.params.userId?.[0]; + const res = response(); + const next = vi.fn(); + + guard?.({ cardavUserId: 'user-1' }, res, next, 'user-2'); + + expect(guard).toBeTypeOf('function'); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.end).toHaveBeenCalledOnce(); + expect(next).not.toHaveBeenCalled(); + }); + + it('continues when the route and authenticated user IDs match', () => { + const guard = router.params.userId?.[0]; + const res = response(); + const next = vi.fn(); + + guard?.({ cardavUserId: 'user-1' }, res, next, 'user-1'); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); +}); + +function response() { + return { + end: vi.fn(), + send: vi.fn(), + set: vi.fn().mockReturnThis(), + setHeader: vi.fn().mockReturnThis(), + status: vi.fn().mockReturnThis(), + }; +} + +function request(overrides = {}) { + return { + cardavUserId: 'user-1', + params: { + userId: 'user-1', + bookId: 'book-1', + filename: 'contact-uid.vcf', + }, + headers: {}, + body: [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:contact-uid', + 'FN:Ada Lovelace', + 'EMAIL:ada@example.test', + 'END:VCARD', + ].join('\r\n'), + ...overrides, + }; +} + +function streamingRequest(chunks, overrides = {}) { + const req = Readable.from(chunks); + return Object.assign(req, request({ body: undefined }), overrides); +} + +function lifecycleRequest(overrides = {}) { + return Object.assign(new EventEmitter(), request({ body: undefined }), { + resume: vi.fn(), + ...overrides, + }); +} + +async function settlementWithin(promise, timeoutMs = 25) { + let timer; + const timeout = new Promise(resolve => { + timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); + }); + const settlement = promise.then( + value => ({ kind: 'resolved', value }), + error => ({ kind: 'rejected', error }), + ); + const result = await Promise.race([settlement, timeout]); + clearTimeout(timer); + return result; +} + +function mockResource(existing = servedContactRow(), created = existing ?? servedContactRow()) { + let contactReads = 0; + mocks.query.mockImplementation(async sql => { + if (sql.includes('FROM address_books') && !sql.includes('JOIN')) { + return { rows: [{ id: 'book-1', sync_token: 'sync-1' }] }; + } + if (sql.includes('FROM contacts c') && sql.includes('c.uid = $3')) { + // First read = existence/precondition check; a later read = post-write served ETag. + contactReads++; + const row = contactReads === 1 ? existing : created; + return { rows: row ? [row] : [] }; + } + if (sql.includes('DELETE FROM contacts')) return { rows: [{ book_id: 'book-1' }] }; + return { rows: [] }; + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('readRawBody stream lifecycle', () => { + it('keeps tail errors protected until an over-limit request finishes draining', async () => { + const tailError = new Error('tail drain failed'); + let unhandledError; + const req = lifecycleRequest(); + req.resume.mockImplementation(() => { + try { + req.emit('error', tailError); + } catch (error) { + unhandledError = error; + } + req.emit('close'); + }); + + const body = readRawBody(req, { maxBytes: 1 }); + req.emit('data', Buffer.from('xx')); + + await expect(body).rejects.toMatchObject({ code: 'ERR_CARDDAV_BODY_TOO_LARGE' }); + expect(unhandledError).toBeUndefined(); + expect(req.resume).toHaveBeenCalledOnce(); + for (const event of ['data', 'end', 'error', 'close', 'aborted']) { + expect(req.listenerCount(event), event).toBe(0); + } + }); + + it.each(['close', 'aborted'])( + 'rejects once and detaches when a partial request emits %s without end/error', + async event => { + const req = lifecycleRequest(); + let settlements = 0; + const body = readRawBody(req, { maxBytes: 10 }).then( + value => { + settlements += 1; + return value; + }, + error => { + settlements += 1; + throw error; + }, + ); + + req.emit('data', Buffer.from('partial')); + req.emit(event); + + const outcome = await settlementWithin(body); + expect(outcome).toMatchObject({ + kind: 'rejected', + error: { code: 'ERR_CARDDAV_REQUEST_ABORTED' }, + }); + req.emit(event === 'close' ? 'aborted' : 'close'); + req.emit('end'); + await Promise.resolve(); + expect(settlements).toBe(1); + for (const listenerEvent of ['data', 'end', 'error', 'close', 'aborted']) { + expect(req.listenerCount(listenerEvent), listenerEvent).toBe(0); + } + }, + ); +}); + +describe('PUT CardDAV contact resource', () => { + it('returns 400 when the request aborts before its body ends', async () => { + const req = lifecycleRequest(); + const res = response(); + + const handling = putHandler(req, res); + req.emit('data', Buffer.from('partial')); + req.emit('aborted'); + + expect(await settlementWithin(handling)).toMatchObject({ kind: 'resolved' }); + expect(res.status).toHaveBeenCalledWith(400); + expect(mocks.createContactFromVCard).not.toHaveBeenCalled(); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + }); + + it('rejects a multi-chunk body at 1 MiB + 1 before delegation, then drains and detaches', async () => { + mockResource(); + const consumed = []; + const req = streamingRequest((function* bodyChunks() { + for (const chunk of [Buffer.alloc(1024 * 1024), Buffer.from('x'), Buffer.from('tail')]) { + consumed.push(chunk.length); + yield chunk; + } + })()); + const setEncoding = vi.spyOn(req, 'setEncoding'); + const ended = once(req, 'end'); + const res = response(); + + await putHandler(req, res); + await ended; + + expect(res.status).toHaveBeenCalledWith(413); + expect(mocks.createContactFromVCard).not.toHaveBeenCalled(); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + expect(setEncoding).not.toHaveBeenCalled(); + expect(consumed).toEqual([1024 * 1024, 1, 4]); + expect(req.readableEnded).toBe(true); + expect(req.listenerCount('data')).toBe(0); + expect(req.listenerCount('end')).toBe(0); + expect(req.listenerCount('error')).toBe(0); + }); + + it.each([ + ['stream', () => streamingRequest([Buffer.alloc(1024 * 1024 - 1), Buffer.from('x')])], + ['string', () => request({ body: '🙂'.repeat(256 * 1024) })], + ['Buffer', () => request({ body: Buffer.alloc(1024 * 1024) })], + ])('allows an exact 1 MiB %s body to reach normal vCard validation', async (_kind, makeRequest) => { + mockResource(); + mocks.replaceContactFromVCard.mockRejectedValueOnce( + Object.assign(new Error('invalid vCard'), { code: 'ERR_CONTACT_VALIDATION' }), + ); + const res = response(); + + await putHandler(makeRequest(), res); + + expect(mocks.replaceContactFromVCard).toHaveBeenCalledOnce(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it.each([ + ['string', '🙂'.repeat(256 * 1024) + 'x'], + ['Buffer', Buffer.alloc(1024 * 1024 + 1)], + ])('rejects a pre-collected %s body at 1 MiB + 1 before delegation', async (_kind, body) => { + mockResource(); + const res = response(); + + await putHandler(request({ body }), res); + + expect(res.status).toHaveBeenCalledWith(413); + expect(mocks.createContactFromVCard).not.toHaveBeenCalled(); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + }); + + it('delegates a mapped replacement exactly once with raw vCard and matching local ETag', async () => { + mockResource(); + mocks.replaceContactFromVCard.mockResolvedValueOnce({ etag: 'confirmed-etag' }); + const req = request({ headers: { 'if-match': SERVED_ETAG } }); + const res = response(); + + await putHandler(req, res); + + expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1); + expect(mocks.replaceContactFromVCard).toHaveBeenCalledWith('user-1', { + localAddressBookId: 'book-1', + uid: 'contact-uid', + rawVCard: req.body, + expectedLocalEtag: 'local-etag', + }); + expect(mocks.createContactFromVCard).not.toHaveBeenCalled(); + expect(res.set).toHaveBeenCalledWith('ETag', SERVED_ETAG); + expect(res.status).toHaveBeenCalledWith(204); + }); + + it('requires a conditional request on a mapped PUT: no If-Match → 428, nothing pushed', async () => { + mockResource(servedContactRow({ + mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n', + })); + const res = response(); + + await putHandler(request(), res); + + expect(res.status).toHaveBeenCalledWith(428); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + }); + + it.each([['star', '*'], ['empty', '']])( + 'rejects a mapped PUT whose If-Match is %s (not a real strong ETag) → 428, nothing pushed', + async (_kind, ifMatch) => { + mockResource(servedContactRow({ + mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n', + })); + const res = response(); + + await putHandler(request({ headers: { 'if-match': ifMatch } }), res); + + expect(res.status).toHaveBeenCalledWith(428); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + }); + + it('delegates a mapped PUT that carries a matching If-Match', async () => { + const mapped = servedContactRow({ + mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n', + }); + mockResource(mapped); + mocks.replaceContactFromVCard.mockResolvedValueOnce({ etag: 'x' }); + const res = response(); + + await putHandler(request({ headers: { 'if-match': `"${presentedEtag(mapped)}"` } }), res); + + expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(204); + }); + + it('delegates a new resource exactly once with its local book, UID, and raw vCard', async () => { + mockResource(null); + mocks.createContactFromVCard.mockResolvedValueOnce({ etag: 'confirmed-etag' }); + const req = request(); + const res = response(); + + await putHandler(req, res); + + expect(mocks.createContactFromVCard).toHaveBeenCalledTimes(1); + expect(mocks.createContactFromVCard).toHaveBeenCalledWith('user-1', { + localAddressBookId: 'book-1', + uid: 'contact-uid', + rawVCard: req.body, + }); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + expect(res.set).toHaveBeenCalledWith('ETag', SERVED_ETAG); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('returns 412 without delegation when If-None-Match requires an existing resource to be absent', async () => { + mockResource(); + const res = response(); + + await putHandler(request({ headers: { 'if-none-match': '*' } }), res); + + expect(mocks.createContactFromVCard).not.toHaveBeenCalled(); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(412); + }); + + it('delegates an absent If-None-Match resource once with the create-only precondition', async () => { + mockResource(null); + mocks.createContactFromVCard.mockResolvedValueOnce({ etag: 'confirmed-etag' }); + const req = request({ headers: { 'if-none-match': ' * ' } }); + const res = response(); + + await putHandler(req, res); + + expect(mocks.createContactFromVCard).toHaveBeenCalledTimes(1); + expect(mocks.createContactFromVCard).toHaveBeenCalledWith('user-1', { + localAddressBookId: 'book-1', + uid: 'contact-uid', + rawVCard: req.body, + expectedAbsent: true, + }); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('maps a typed local create-only failure to 412', async () => { + mockResource(null); + mocks.createContactFromVCard.mockRejectedValueOnce(Object.assign( + new Error('The contact already exists'), + { code: 'ERR_LOCAL_PRECONDITION_FAILED' }, + )); + const res = response(); + + await putHandler(request({ headers: { 'if-none-match': '*' } }), res); + + expect(res.status).toHaveBeenCalledWith(412); + expect(res.end).toHaveBeenCalled(); + }); + + it('returns 412 without delegation when If-Match does not match confirmed local state', async () => { + mockResource(); + const res = response(); + + await putHandler(request({ headers: { 'if-match': '"stale-etag"' } }), res); + + expect(mocks.createContactFromVCard).not.toHaveBeenCalled(); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(412); + }); + + it('propagates an upstream failure without route-level local mutation', async () => { + mockResource(); + mocks.replaceContactFromVCard.mockRejectedValueOnce(Object.assign(new Error('Unavailable'), { status: 503 })); + const res = response(); + + await putHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res); + + expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1); + expect(mocks.query.mock.calls.every(([sql]) => /^\s*SELECT\b/.test(sql))).toBe(true); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.end).toHaveBeenCalled(); + }); + + it.each([ + ['ERR_CARDDAV_FINAL_FENCE'], + ['ERR_CARDDAV_STALE_GENERATION'], + ])('maps the self-healing %s race to a retriable 503 for external DAV clients', async code => { + mockResource(); + mocks.replaceContactFromVCard.mockRejectedValueOnce( + Object.assign(new Error('mapping changed after the remote write'), { code }), + ); + const res = response(); + + await putHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res); + + expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.end).toHaveBeenCalled(); + }); + + it.each([ + ['ERR_CARDDAV_AMBIGUOUS_WRITE'], + ['ERR_CARDDAV_PENDING_INTENT'], + ])('maps the post-write %s to a non-retriable 409 for external DAV clients', async code => { + mockResource(); + mocks.replaceContactFromVCard.mockRejectedValueOnce( + Object.assign(new Error('post-write state is indeterminate'), { code }), + ); + const res = response(); + + await putHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res); + + expect(mocks.replaceContactFromVCard).toHaveBeenCalledTimes(1); + // 409 (not a retriable 5xx) so DAV clients refresh state instead of re-issuing + // a mutation whose remote effect may already have landed. + expect(res.status).toHaveBeenCalledWith(409); + expect(res.end).toHaveBeenCalled(); + }); +}); + +describe('DELETE CardDAV contact resource', () => { + it.each([['absent', undefined], ['star', '*'], ['empty', '']])( + 'requires a real strong If-Match on a mapped DELETE (%s) → 428, nothing deleted', + async (_kind, ifMatch) => { + mockResource(servedContactRow({ + mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Confirmed\r\nEND:VCARD\r\n', + })); + const res = response(); + + await deleteHandler(request({ headers: ifMatch === undefined ? {} : { 'if-match': ifMatch } }), res); + + expect(res.status).toHaveBeenCalledWith(428); + expect(mocks.deleteContactFromVCard).not.toHaveBeenCalled(); + }); + + it('delegates exactly once with matching local ETag', async () => { + mockResource(); + mocks.deleteContactFromVCard.mockResolvedValueOnce({ ok: true }); + const res = response(); + + await deleteHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res); + + expect(mocks.deleteContactFromVCard).toHaveBeenCalledTimes(1); + expect(mocks.deleteContactFromVCard).toHaveBeenCalledWith('user-1', { + localAddressBookId: 'book-1', + uid: 'contact-uid', + expectedLocalEtag: 'local-etag', + }); + expect(res.status).toHaveBeenCalledWith(204); + }); + + it('returns 412 without delegation when If-Match is stale', async () => { + mockResource(); + const res = response(); + + await deleteHandler(request({ headers: { 'if-match': '"stale-etag"' } }), res); + + expect(mocks.deleteContactFromVCard).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(412); + }); + + it('propagates an upstream failure without route-level local mutation', async () => { + mockResource(); + mocks.deleteContactFromVCard.mockRejectedValueOnce(Object.assign(new Error('Unavailable'), { status: 503 })); + const res = response(); + + await deleteHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res); + + expect(mocks.deleteContactFromVCard).toHaveBeenCalledTimes(1); + expect(mocks.query.mock.calls.every(([sql]) => /^\s*SELECT\b/.test(sql))).toBe(true); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.end).toHaveBeenCalled(); + }); + + it.each([ + ['ERR_CARDDAV_AMBIGUOUS_WRITE'], + ['ERR_CARDDAV_PENDING_INTENT'], + ])('maps the post-write %s delete to a non-retriable 409 for external DAV clients', async code => { + mockResource(); + mocks.deleteContactFromVCard.mockRejectedValueOnce( + Object.assign(new Error('post-write state is indeterminate'), { code }), + ); + const res = response(); + + await deleteHandler(request({ headers: { 'if-match': SERVED_ETAG } }), res); + + expect(mocks.deleteContactFromVCard).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.end).toHaveBeenCalled(); + }); +}); + +describe('CardDAV confirmed local reads', () => { + it('GET keeps serving the confirmed local vCard and the presented ETag', async () => { + const row = { vcard: 'BEGIN:VCARD\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD', etag: 'confirmed-etag' }; + mocks.query.mockResolvedValueOnce({ rows: [row] }); + const res = response(); + + await getHandler(request(), res); + + expect(res.set).toHaveBeenCalledWith({ + 'Content-Type': 'text/vcard;charset=utf-8', + 'ETag': `"${presentedEtag(row)}"`, + }); + expect(res.send).toHaveBeenCalledWith('BEGIN:VCARD\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD'); + }); + + it.each([ + ['string', '🙂'.repeat(256 * 1024) + 'x'], + ['Buffer', Buffer.alloc(1024 * 1024 + 1)], + ])('rejects a REPORT %s body at 1 MiB + 1 before scanning it', async (_kind, body) => { + mocks.query.mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] }); + const res = response(); + + await reportHandler(request({ body }), res); + + expect(res.status).toHaveBeenCalledWith(413); + // The oversized body is rejected before any contact vCards are queried. + expect(mocks.query).toHaveBeenCalledTimes(1); + expect(res.send).not.toHaveBeenCalled(); + }); + + it('rejects a multi-chunk REPORT body at 1 MiB + 1, then drains and detaches', async () => { + mocks.query.mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] }); + const req = streamingRequest((function* bodyChunks() { + yield Buffer.alloc(1024 * 1024); + yield Buffer.from('x'); + yield Buffer.from('tail'); + })()); + const ended = once(req, 'end'); + const res = response(); + + await reportHandler(req, res); + await ended; + + expect(res.status).toHaveBeenCalledWith(413); + expect(mocks.query).toHaveBeenCalledTimes(1); + expect(req.readableEnded).toBe(true); + expect(req.listenerCount('data')).toBe(0); + }); + + it('GET serves the retained remote document overlaid with the local edit for a mapped contact', async () => { + const row = { + uid: 'contact-uid', + display_name: 'Confirmed Local', + first_name: null, + last_name: null, + emails: [{ value: 'mapped@example.test', type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photo_data: null, + additional_fields: [], + vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact-uid\r\nFN:Confirmed Local\r\nEMAIL:mapped@example.test\r\nEND:VCARD\r\n', + etag: 'confirmed-etag', + mapping_vcard: [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:remote-uid-xyz', 'FN:Old Remote Name', + 'EMAIL:mapped@example.test', 'CATEGORIES:VIP,Board', 'X-CUSTOM-FLAG:keep-me', + 'TZ:America/New_York', 'END:VCARD', '', + ].join('\r\n'), + }; + mocks.query.mockResolvedValueOnce({ rows: [row] }); + const res = response(); + + await getHandler(request(), res); + + // The served ETag derives from the presented document, not contacts.etag. + expect(res.set).toHaveBeenCalledWith(expect.objectContaining({ 'ETag': `"${presentedEtag(row)}"` })); + const body = res.send.mock.calls[0][0]; + // The local modeled edit wins, the retained unmodeled properties are visible to + // the client, and the served UID stays the local UID that keys the resource URL. + expect(body).toContain('FN:Confirmed Local'); + expect(body).toContain('CATEGORIES:VIP,Board'); + expect(body).toContain('X-CUSTOM-FLAG:keep-me'); + expect(body).toContain('TZ:America/New_York'); + expect(body).toContain('UID:contact-uid'); + expect(body).not.toContain('remote-uid-xyz'); + }); + + it('REPORT serves the retained remote document for a mapped contact', async () => { + const row = { + uid: 'contact-uid', + display_name: 'Confirmed Local', + first_name: null, + last_name: null, + emails: [{ value: 'mapped@example.test', type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photo_data: null, + additional_fields: [], + vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact-uid\r\nFN:Confirmed Local\r\nEMAIL:mapped@example.test\r\nEND:VCARD\r\n', + etag: 'confirmed-etag', + mapping_vcard: [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:remote-uid-xyz', 'FN:Old Remote Name', + 'EMAIL:mapped@example.test', 'CATEGORIES:VIP,Board', 'END:VCARD', '', + ].join('\r\n'), + }; + mocks.query + .mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] }) + .mockResolvedValueOnce({ rows: [row] }); + const res = response(); + + await reportHandler(request({ body: '' }), res); + + const xml = res.send.mock.calls[0][0]; + expect(xml).toContain(`"${presentedEtag(row)}"`); + expect(xml).toContain('FN:Confirmed Local'); + expect(xml).toContain('CATEGORIES:VIP,Board'); + }); + + it('REPORT keeps serving confirmed local address data and the presented ETag', async () => { + const row = { + uid: 'contact-uid', + vcard: 'BEGIN:VCARD\r\nUID:contact-uid\r\nFN:Confirmed\r\nEND:VCARD', + etag: 'confirmed-etag', + }; + mocks.query + .mockResolvedValueOnce({ rows: [{ id: 'book-1', sync_token: 'sync-1' }] }) + .mockResolvedValueOnce({ rows: [row] }); + const res = response(); + + await reportHandler(request({ body: '' }), res); + + const xml = res.send.mock.calls[0][0]; + expect(xml).toContain(`"${presentedEtag(row)}"`); + expect(xml).toContain('FN:Confirmed'); + expect(mocks.createContactFromVCard).not.toHaveBeenCalled(); + expect(mocks.replaceContactFromVCard).not.toHaveBeenCalled(); + expect(mocks.deleteContactFromVCard).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/routes/carddavAccount.js b/backend/src/routes/carddavAccount.js index 8495cdaf..d3c0eb8f 100644 --- a/backend/src/routes/carddavAccount.js +++ b/backend/src/routes/carddavAccount.js @@ -4,18 +4,24 @@ // distinct from routes/carddav.js, which is the CardDAV *server* MailFlow exposes. import { Router } from 'express'; -import { query } from '../services/db.js'; import { requireAuth } from '../middleware/auth.js'; import { encrypt } from '../services/encryption.js'; import { validateHost } from '../services/hostValidation.js'; import { getConnectionPolicy } from '../services/connectionPolicy.js'; import { discoverAddressBooks } from '../services/carddavClient.js'; -import { syncUser, scheduleCardavUser, stopCardavUser, getCardavConfig } from '../services/carddavSync.js'; +import { + syncUser, + requestCarddavSync, + scheduleCardavUser, + getCardavConfig, + replaceCarddavConnection, + patchCarddavConnection, + disconnectCarddavAccount, +} from '../services/carddavSync.js'; const router = Router(); router.use(requireAuth); -const DUP_MODES = ['separate', 'merge', 'skip']; const clampInterval = (v) => Math.max(15, Math.min(1440, parseInt(v) || 60)); // Public view of the connection — never leaks the stored password. @@ -25,7 +31,6 @@ function publicStatus(config) { connected: true, serverUrl: config.serverUrl, username: config.username, - dupMode: config.dupMode || 'separate', intervalMin: config.intervalMin || 60, lastSyncAt: config.lastSyncAt || null, lastError: config.lastError || null, @@ -39,7 +44,7 @@ router.get('/', async (req, res) => { }); router.post('/connect', async (req, res) => { - const { serverUrl, username, password, dupMode, intervalMin } = req.body || {}; + const { serverUrl, username, password, intervalMin } = req.body || {}; if (!serverUrl || !username || !password) { return res.status(400).json({ error: 'Server URL, username, and password are required' }); } @@ -74,43 +79,47 @@ router.post('/connect', async (req, res) => { return res.status(400).json({ error: err.message }); } - const config = { + const connection = { serverUrl, username, password: encrypt(password), - dupMode: DUP_MODES.includes(dupMode) ? dupMode : 'separate', intervalMin: clampInterval(intervalMin), - lastError: null, }; - await query( - `INSERT INTO user_integrations (user_id, provider, config) - VALUES ($1, 'carddav', $2::jsonb) - ON CONFLICT (user_id, provider) DO UPDATE SET config = $2::jsonb, updated_at = NOW()`, - [req.session.userId, JSON.stringify(config)], - ); + const config = await replaceCarddavConnection(req.session.userId, connection); scheduleCardavUser(req.session.userId, config.intervalMin); // Kick off the first sync in the background; the client polls GET / for status. - syncUser(req.session.userId).catch(() => {}); + requestCarddavSync(req.session.userId, config.connectionGeneration); res.json(publicStatus(config)); }); -// Update duplicate handling / interval (and optionally rotate the password). +// Update the interval (and optionally rotate the password). router.patch('/', async (req, res) => { const existing = await getCardavConfig(req.session.userId); if (!existing?.serverUrl) return res.status(409).json({ error: 'CardDAV not connected' }); const patch = {}; - if (req.body.dupMode && DUP_MODES.includes(req.body.dupMode)) patch.dupMode = req.body.dupMode; if (req.body.intervalMin != null) patch.intervalMin = clampInterval(req.body.intervalMin); - if (req.body.password) patch.password = encrypt(req.body.password); + if (req.body.password) { + const policy = await getConnectionPolicy(); + try { + await discoverAddressBooks({ + serverUrl: existing.serverUrl, + username: existing.username, + password: req.body.password, + allowPrivate: policy.allowPrivateHosts, + }); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + patch.password = encrypt(req.body.password); + } - await query( - `UPDATE user_integrations SET config = config || $2::jsonb, updated_at = NOW() - WHERE user_id = $1 AND provider = 'carddav'`, - [req.session.userId, JSON.stringify(patch)], - ); + const config = patch.password + ? await patchCarddavConnection(req.session.userId, patch, existing.connectionGeneration) + : await patchCarddavConnection(req.session.userId, patch); if (patch.intervalMin) scheduleCardavUser(req.session.userId, patch.intervalMin); - res.json(publicStatus({ ...existing, ...patch })); + if (patch.password) requestCarddavSync(req.session.userId, config.connectionGeneration); + res.json(publicStatus(config)); }); router.post('/sync', async (req, res) => { @@ -121,16 +130,7 @@ router.post('/sync', async (req, res) => { }); router.delete('/', async (req, res) => { - stopCardavUser(req.session.userId); - // Remove the synced (read-only) address books; contacts cascade with them. - await query( - "DELETE FROM address_books WHERE user_id = $1 AND source = 'carddav'", - [req.session.userId], - ); - await query( - "DELETE FROM user_integrations WHERE user_id = $1 AND provider = 'carddav'", - [req.session.userId], - ); + await disconnectCarddavAccount(req.session.userId); res.json({ ok: true }); }); diff --git a/backend/src/routes/carddavAccount.test.js b/backend/src/routes/carddavAccount.test.js new file mode 100644 index 00000000..9b3b20cf --- /dev/null +++ b/backend/src/routes/carddavAccount.test.js @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + discoverAddressBooks: vi.fn(), + encrypt: vi.fn(value => `encrypted:${value}`), + getCardavConfig: vi.fn(), + replaceCarddavConnection: vi.fn(), + patchCarddavConnection: vi.fn(), + requestCarddavSync: vi.fn(), + scheduleCardavUser: vi.fn(), + syncUser: vi.fn(), + disconnectCarddavAccount: vi.fn(), +})); + +vi.mock('../services/db.js', () => ({ query: mocks.query })); +vi.mock('../middleware/auth.js', () => ({ requireAuth: vi.fn((req, res, next) => next()) })); +vi.mock('../services/encryption.js', () => ({ encrypt: mocks.encrypt })); +vi.mock('../services/hostValidation.js', () => ({ validateHost: vi.fn() })); +vi.mock('../services/connectionPolicy.js', () => ({ + getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })), +})); +vi.mock('../services/carddavClient.js', () => ({ + discoverAddressBooks: mocks.discoverAddressBooks, +})); +vi.mock('../services/carddavSync.js', () => ({ + getCardavConfig: mocks.getCardavConfig, + replaceCarddavConnection: mocks.replaceCarddavConnection, + patchCarddavConnection: mocks.patchCarddavConnection, + requestCarddavSync: mocks.requestCarddavSync, + scheduleCardavUser: mocks.scheduleCardavUser, + syncUser: mocks.syncUser, + disconnectCarddavAccount: mocks.disconnectCarddavAccount, +})); + +const { default: router } = await import('./carddavAccount.js'); +const connectHandler = router.stack + .find(layer => layer.route?.path === '/connect' && layer.route.methods.post) + .route.stack.at(-1).handle; +const patchHandler = router.stack + .find(layer => layer.route?.path === '/' && layer.route.methods.patch) + .route.stack.at(-1).handle; +const syncHandler = router.stack + .find(layer => layer.route?.path === '/sync' && layer.route.methods.post) + .route.stack.at(-1).handle; +const deleteHandler = router.stack + .find(layer => layer.route?.path === '/' && layer.route.methods.delete) + .route.stack.at(-1).handle; + +function response() { + return { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + }; +} + +describe('POST /api/carddav/connect', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.discoverAddressBooks.mockResolvedValue([]); + mocks.replaceCarddavConnection.mockResolvedValue({ + serverUrl: 'https://dav.example.test/', + username: 'user', + intervalMin: 30, + connectionGeneration: 'generation-b', + lastError: null, + }); + mocks.requestCarddavSync.mockReturnValue(true); + }); + + it('delegates replacement atomically and requests its committed generation', async () => { + const res = response(); + + await connectHandler({ + session: { userId: 'user-1' }, + body: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'secret', + dupMode: 'merge', + intervalMin: 30, + }, + }, res); + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.replaceCarddavConnection).toHaveBeenCalledWith('user-1', { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted:secret', + intervalMin: 30, + }); + expect(mocks.requestCarddavSync).toHaveBeenCalledWith('user-1', 'generation-b'); + expect(mocks.replaceCarddavConnection.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.requestCarddavSync.mock.invocationCallOrder[0]); + expect(mocks.scheduleCardavUser).toHaveBeenCalledWith('user-1', 30); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + connected: true, + })); + expect(res.json.mock.calls[0][0]).not.toHaveProperty('dupMode'); + expect(res.json.mock.calls[0][0]).not.toHaveProperty('connectionGeneration'); + }); + + it('does not request a sync when replacement persistence fails', async () => { + mocks.replaceCarddavConnection.mockRejectedValueOnce(new Error('replace failed')); + const res = response(); + + await expect(connectHandler({ + session: { userId: 'user-1' }, + body: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'secret', + dupMode: 'merge', + }, + }, res)).rejects.toThrow('replace failed'); + + expect(mocks.requestCarddavSync).not.toHaveBeenCalled(); + expect(mocks.scheduleCardavUser).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); +}); + +describe('PATCH /api/carddav', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCardavConfig.mockResolvedValue({ + serverUrl: 'https://dav.example.test/', + username: 'user', + intervalMin: 60, + connectionGeneration: 'generation-a', + }); + mocks.discoverAddressBooks.mockResolvedValue([]); + mocks.patchCarddavConnection.mockResolvedValue({ + serverUrl: 'https://dav.example.test/', + username: 'user', + intervalMin: 60, + connectionGeneration: 'generation-a', + }); + mocks.requestCarddavSync.mockReturnValue(true); + }); + + it('ignores obsolete duplicate-mode input while delegating the interval', async () => { + const req = { + session: { userId: 'user-1' }, + body: { dupMode: 'merge', intervalMin: 30 }, + }; + const res = response(); + mocks.patchCarddavConnection.mockResolvedValueOnce({ + serverUrl: 'https://dav.example.test/', + username: 'user', + intervalMin: 30, + connectionGeneration: 'generation-a', + }); + + await patchHandler(req, res); + + expect(mocks.patchCarddavConnection).toHaveBeenCalledWith('user-1', { + intervalMin: 30, + }); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.requestCarddavSync).not.toHaveBeenCalled(); + expect(mocks.scheduleCardavUser).toHaveBeenCalledWith('user-1', 30); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + connected: true, + })); + expect(res.json.mock.calls[0][0]).not.toHaveProperty('dupMode'); + }); + + it('preflights a password replacement before persisting and queues its generation', async () => { + const res = response(); + mocks.patchCarddavConnection.mockResolvedValueOnce({ + serverUrl: 'https://dav.example.test/', + username: 'user', + intervalMin: 60, + connectionGeneration: 'generation-b', + }); + + await patchHandler({ + session: { userId: 'user-1' }, + body: { password: 'new-secret' }, + }, res); + + expect(mocks.discoverAddressBooks).toHaveBeenCalledWith({ + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'new-secret', + allowPrivate: false, + }); + expect(mocks.patchCarddavConnection).toHaveBeenCalledWith( + 'user-1', + { password: 'encrypted:new-secret' }, + 'generation-a', + ); + expect(mocks.requestCarddavSync).toHaveBeenCalledWith('user-1', 'generation-b'); + expect(mocks.discoverAddressBooks.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.patchCarddavConnection.mock.invocationCallOrder[0]); + expect(mocks.patchCarddavConnection.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.requestCarddavSync.mock.invocationCallOrder[0]); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it('changes and queues nothing when password preflight fails', async () => { + mocks.discoverAddressBooks.mockRejectedValueOnce(new Error('bad credentials')); + const res = response(); + + await patchHandler({ + session: { userId: 'user-1' }, + body: { password: 'bad-secret' }, + }, res); + + expect(mocks.patchCarddavConnection).not.toHaveBeenCalled(); + expect(mocks.requestCarddavSync).not.toHaveBeenCalled(); + expect(mocks.query).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'bad credentials' }); + }); +}); + +describe('POST /api/carddav/sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCardavConfig + .mockResolvedValueOnce({ serverUrl: 'https://dav.example.test/' }) + .mockResolvedValueOnce({ + serverUrl: 'https://dav.example.test/', + username: 'user', + bookCount: 1, + contactCount: 2, + }); + }); + + it('preserves the result-plus-status envelope while exposing counters', async () => { + const result = { + ok: true, + bookCount: 1, + contactCount: 2, + remote: 3, + fetched: 1, + updated: 1, + removed: 0, + fallback: 0, + }; + mocks.syncUser.mockResolvedValue(result); + const res = response(); + + await syncHandler({ session: { userId: 'user-1' } }, res); + + expect(res.json).toHaveBeenCalledWith({ + ...result, + status: expect.objectContaining({ + connected: true, + bookCount: 1, + contactCount: 2, + }), + }); + }); +}); + +describe('DELETE /api/carddav', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.disconnectCarddavAccount.mockResolvedValue(true); + }); + + it('delegates disconnect once without route lifecycle SQL', async () => { + const res = response(); + + await deleteHandler({ session: { userId: 'user-1' } }, res); + + expect(mocks.disconnectCarddavAccount).toHaveBeenCalledTimes(1); + expect(mocks.disconnectCarddavAccount).toHaveBeenCalledWith('user-1'); + expect(mocks.query).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + }); +}); diff --git a/backend/src/routes/carddavConflicts.js b/backend/src/routes/carddavConflicts.js new file mode 100644 index 00000000..ed168c18 --- /dev/null +++ b/backend/src/routes/carddavConflicts.js @@ -0,0 +1,45 @@ +import { Router } from 'express'; + +import { requireAuth } from '../middleware/auth.js'; +import { + getConflict, + listConflicts, + resolveConflict, +} from '../services/carddavConflictService.js'; + +const router = Router(); +router.use(requireAuth); + +function conflictError(res, error) { + const status = { + ERR_CARDDAV_CONFLICT_RESOLUTION: 400, + ERR_CARDDAV_CONFLICT_NOT_FOUND: 404, + ERR_CARDDAV_CONFLICT_STALE: 409, + }[error.code]; + if (status) return res.status(status).json({ error: error.message }); + throw error; +} + +router.get('/', async (req, res) => { + res.json({ conflicts: await listConflicts(req.session.userId) }); +}); + +router.get('/:id', async (req, res) => { + const conflict = await getConflict(req.session.userId, req.params.id); + if (!conflict) return res.status(404).json({ error: 'CardDAV conflict not found' }); + res.json(conflict); +}); + +router.post('/:id/resolve', async (req, res) => { + try { + res.json(await resolveConflict( + req.session.userId, + req.params.id, + req.body?.resolution, + )); + } catch (error) { + conflictError(res, error); + } +}); + +export default router; diff --git a/backend/src/routes/carddavConflicts.test.js b/backend/src/routes/carddavConflicts.test.js new file mode 100644 index 00000000..7794d39c --- /dev/null +++ b/backend/src/routes/carddavConflicts.test.js @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getConflict: vi.fn(), + listConflicts: vi.fn(), + requireAuth: vi.fn((req, res, next) => next()), + resolveConflict: vi.fn(), +})); + +vi.mock('../middleware/auth.js', () => ({ requireAuth: mocks.requireAuth })); +vi.mock('../services/carddavConflictService.js', () => ({ + getConflict: mocks.getConflict, + listConflicts: mocks.listConflicts, + resolveConflict: mocks.resolveConflict, +})); + +const { default: router } = await import('./carddavConflicts.js'); + +function handler(method, path) { + return router.stack + .find(layer => layer.route?.path === path && layer.route.methods[method]) + .route.stack.at(-1).handle; +} + +const listHandler = handler('get', '/'); +const detailHandler = handler('get', '/:id'); +const resolveHandler = handler('post', '/:id/resolve'); + +function response() { + return { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('CardDAV conflict routes', () => { + it('gates every route through the shared authentication middleware', () => { + expect(router.stack[0].handle).toBe(mocks.requireAuth); + }); + + it('lists the current user conflict comparison envelope', async () => { + const conflicts = [{ id: 'conflict-1', local: {}, remote: {} }]; + mocks.listConflicts.mockResolvedValueOnce(conflicts); + const res = response(); + + await listHandler({ session: { userId: 'user-1' } }, res); + + expect(mocks.listConflicts).toHaveBeenCalledWith('user-1'); + expect(res.json).toHaveBeenCalledWith({ conflicts }); + }); + + it('returns 404 when an owned conflict detail does not exist', async () => { + mocks.getConflict.mockResolvedValueOnce(null); + const res = response(); + + await detailHandler({ + session: { userId: 'user-1' }, + params: { id: 'conflict-1' }, + }, res); + + expect(mocks.getConflict).toHaveBeenCalledWith('user-1', 'conflict-1'); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'CardDAV conflict not found' }); + }); + + it('delegates only the authenticated user, conflict ID, and exact resolution', async () => { + const conflict = { id: 'conflict-1', status: 'resolved', resolution: 'keep-mailflow' }; + mocks.resolveConflict.mockResolvedValueOnce(conflict); + const res = response(); + + await resolveHandler({ + session: { userId: 'user-1' }, + params: { id: 'conflict-1' }, + body: { + resolution: 'keep-mailflow', + contactId: 'attacker-supplied-contact', + addressBookId: 'attacker-supplied-book', + }, + }, res); + + expect(mocks.resolveConflict).toHaveBeenCalledWith( + 'user-1', + 'conflict-1', + 'keep-mailflow', + ); + expect(res.json).toHaveBeenCalledWith(conflict); + }); + + it('maps invalid resolutions to 400 and stale or resolved transitions to 409', async () => { + const invalid = Object.assign(new Error('Invalid CardDAV conflict resolution'), { + code: 'ERR_CARDDAV_CONFLICT_RESOLUTION', + }); + const stale = Object.assign(new Error('CardDAV conflict is stale'), { + code: 'ERR_CARDDAV_CONFLICT_STALE', + }); + + for (const [error, status] of [[invalid, 400], [stale, 409]]) { + mocks.resolveConflict.mockRejectedValueOnce(error); + const res = response(); + await resolveHandler({ + session: { userId: 'user-1' }, + params: { id: 'conflict-1' }, + body: { resolution: 'bad' }, + }, res); + expect(res.status).toHaveBeenCalledWith(status); + expect(res.json).toHaveBeenCalledWith({ error: error.message }); + } + }); +}); diff --git a/backend/src/routes/contacts.js b/backend/src/routes/contacts.js index 8c096f1b..15dc71cf 100644 --- a/backend/src/routes/contacts.js +++ b/backend/src/routes/contacts.js @@ -1,31 +1,93 @@ import { Router } from 'express'; import { query } from '../services/db.js'; import { requireAuth } from '../middleware/auth.js'; -import { generateVCard } from '../utils/vcard.js'; -import crypto from 'crypto'; +import { + CARDDAV_CONTACT_ERROR_STATUS, + createContact, + deleteContact, + updateContact, +} from '../services/carddavContactService.js'; const router = Router(); router.use(requireAuth); -// Resolve the user's default address book id, creating it if needed. -async function defaultAddressBook(userId) { - const r = await query( - `INSERT INTO address_books (user_id, name) - VALUES ($1, 'Personal') - ON CONFLICT (user_id, name) DO UPDATE SET updated_at = NOW() - RETURNING id`, - [userId] - ); - return r.rows[0].id; +const MAX_PHOTO_BYTES = 512 * 1024; + +const CONTACT_SYNC_COLUMNS = ` + COALESCE(mapping.mapping_status, 'local') AS sync_state, + remote_book.remote_create_capability, + remote_book.remote_update_capability, + remote_book.remote_delete_capability, + (c.photo_data IS NOT NULL) AS has_photo, + conflict.id AS conflict_id, + (mapping.local_contact_id IS NOT NULL + AND remote_book.remote_update_capability = 'denied' + AND remote_book.remote_delete_capability = 'denied') AS read_only`; +const CONTACT_SYNC_JOINS = ` + LEFT JOIN carddav_remote_objects mapping + ON mapping.local_contact_id = c.id + AND mapping.mapping_status <> 'pending_materialization' + LEFT JOIN address_books remote_book ON remote_book.id = mapping.address_book_id + LEFT JOIN carddav_conflicts conflict + ON conflict.address_book_id = mapping.address_book_id + AND conflict.href = mapping.href + AND conflict.status = 'unresolved'`; + +function validateDraft(draft) { + if (draft.emails !== undefined && !Array.isArray(draft.emails)) return 'emails must be an array'; + if (draft.phones !== undefined && !Array.isArray(draft.phones)) return 'phones must be an array'; + if (draft.additionalFields !== undefined && !Array.isArray(draft.additionalFields)) { + return 'additionalFields must be an array'; + } + if (draft.photoData === undefined || draft.photoData === null) return null; + if (typeof draft.photoData !== 'string') return 'photoData must be a JPEG or PNG data URI'; + + const match = /^data:([^;,]+);base64,(.*)$/i.exec(draft.photoData); + if (!match || !/^image\/(?:jpeg|png)$/i.test(match[1])) { + return 'photoData must be a JPEG or PNG data URI'; + } + const encoded = match[2]; + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) { + return 'photoData must contain valid base64 data'; + } + if (Buffer.from(encoded, 'base64').length > MAX_PHOTO_BYTES) { + return 'photoData must not exceed 512 KiB'; + } + return null; } -// Bump the address book sync_token so CardDAV clients re-sync. -async function bumpSyncToken(addressBookId) { - await query( - `UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() - WHERE id = $1`, - [addressBookId] - ); +function contactError(res, err, fallback) { + if (err.code === 'ERR_CARDDAV_CONFLICT') { + return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code]).json({ conflictId: err.conflictId }); + } + // A concurrent sync (or reconnect) can move the mapping fence out from under an + // in-flight write whose remote effect already landed — a transient, self-healing + // race, not a client error. Surface it as retriable with a machine-readable code + // so the client re-issues the edit instead of treating it as a 500. + // (ERR_CARDDAV_STALE_GENERATION is a defensive mapping — no current contacts-route + // path throws it; only exportExistingContact does, and its caller handles it.) + if (err.code === 'ERR_CARDDAV_FINAL_FENCE' || err.code === 'ERR_CARDDAV_STALE_GENERATION') { + return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code]) + .json({ error: err.message, code: err.code, retriable: true }); + } + // A post-write ambiguous result (the remote effect may already have landed; the + // service persisted a pending intent and recovered read-only) or a rejected write + // because another mutation is still awaiting confirmation. Neither is safe to + // re-issue — the client must refresh state and let the next sync reconcile, so map + // to 409 with `refresh` and deliberately WITHOUT `retriable`. + if (err.code === 'ERR_CARDDAV_AMBIGUOUS_WRITE' || err.code === 'ERR_CARDDAV_PENDING_INTENT') { + return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code]) + .json({ error: err.message, code: err.code, refresh: true }); + } + if (err.code === '23505') { + return res.status(CARDDAV_CONTACT_ERROR_STATUS[err.code]) + .json({ error: 'A contact with that email already exists' }); + } + const status = CARDDAV_CONTACT_ERROR_STATUS[err.code] + ?? (Number.isInteger(err.status) ? err.status : null); + if (status) return res.status(status).json({ error: err.message }); + console.error(`${fallback}:`, err); + return res.status(500).json({ error: fallback }); } // GET /api/contacts @@ -63,16 +125,19 @@ router.get('/', async (req, res) => { SELECT c.id, c.uid, c.display_name, c.first_name, c.last_name, c.primary_email, c.emails, c.phones, c.organization, - c.notes, c.is_auto, c.send_count, c.last_sent, + c.notes, c.additional_fields, + c.is_auto, c.send_count, c.last_sent, c.etag, c.created_at, c.updated_at, - (ab.source = 'carddav') AS read_only + ${CONTACT_SYNC_COLUMNS} FROM contacts c JOIN address_books ab ON ab.id = c.address_book_id + ${CONTACT_SYNC_JOINS} WHERE ${conditions.join(' AND ')} ORDER BY c.is_auto ASC, c.send_count DESC, - lower(coalesce(c.display_name, c.primary_email, '')) ASC + lower(coalesce(c.display_name, c.primary_email, '')) ASC, + c.id ASC LIMIT $${p} OFFSET $${p + 1} `, [...params, cap, off]); @@ -135,11 +200,13 @@ router.get('/:id', async (req, res) => { const result = await query( `SELECT c.id, c.uid, c.display_name, c.first_name, c.last_name, c.primary_email, c.emails, c.phones, c.organization, - c.notes, c.photo_data, c.is_auto, c.send_count, c.last_sent, - c.etag, c.vcard, c.created_at, c.updated_at, - (ab.source = 'carddav') AS read_only + c.notes, c.photo_data, c.additional_fields, + c.is_auto, c.send_count, c.last_sent, + c.etag, c.created_at, c.updated_at, + ${CONTACT_SYNC_COLUMNS} FROM contacts c JOIN address_books ab ON ab.id = c.address_book_id + ${CONTACT_SYNC_JOINS} WHERE c.id = $1 AND c.user_id = $2`, [req.params.id, userId] ); @@ -154,128 +221,28 @@ router.get('/:id', async (req, res) => { // POST /api/contacts router.post('/', async (req, res) => { const userId = req.session.userId; - const { - displayName, firstName, lastName, - emails = [], phones = [], - organization, notes, - } = req.body || {}; - - if (!Array.isArray(emails)) return res.status(400).json({ error: 'emails must be an array' }); - if (!Array.isArray(phones)) return res.status(400).json({ error: 'phones must be an array' }); - - const primaryEmail = emails[0]?.value - ? emails[0].value.toLowerCase().trim() - : null; - - if (!displayName && !primaryEmail) { - return res.status(400).json({ error: 'A name or email address is required' }); - } + const draft = req.body || {}; + const validationError = validateDraft(draft); + if (validationError) return res.status(400).json({ error: validationError }); try { - const addressBookId = await defaultAddressBook(userId); - const uid = crypto.randomUUID(); - const vcard = generateVCard({ uid, displayName, firstName, lastName, emails, phones, organization, notes }); - const etag = crypto.createHash('md5').update(vcard).digest('hex'); - - const result = await query(` - INSERT INTO contacts ( - address_book_id, user_id, uid, vcard, etag, - display_name, first_name, last_name, primary_email, - emails, phones, organization, notes, is_auto - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, false) - RETURNING id, uid, display_name, first_name, last_name, - primary_email, emails, phones, organization, notes, - is_auto, send_count, last_sent, etag, created_at, updated_at - `, [ - addressBookId, userId, uid, vcard, etag, - displayName || null, firstName || null, lastName || null, primaryEmail, - JSON.stringify(emails), JSON.stringify(phones), - organization || null, notes || null, - ]); - - await bumpSyncToken(addressBookId); - res.status(201).json(result.rows[0]); + res.status(201).json(await createContact(userId, draft)); } catch (err) { - if (err.code === '23505') return res.status(409).json({ error: 'A contact with that email already exists' }); - console.error('Contact create error:', err); - res.status(500).json({ error: 'Failed to create contact' }); + contactError(res, err, 'Failed to create contact'); } }); // PATCH /api/contacts/:id router.patch('/:id', async (req, res) => { const userId = req.session.userId; - const { - displayName, firstName, lastName, - emails, phones, organization, notes, - } = req.body || {}; - - if (emails !== undefined && !Array.isArray(emails)) return res.status(400).json({ error: 'emails must be an array' }); - if (phones !== undefined && !Array.isArray(phones)) return res.status(400).json({ error: 'phones must be an array' }); + const draft = req.body || {}; + const validationError = validateDraft(draft); + if (validationError) return res.status(400).json({ error: validationError }); try { - // Load current contact (with its book source to block edits to synced contacts) - const cur = await query( - `SELECT c.*, ab.source AS book_source FROM contacts c - JOIN address_books ab ON ab.id = c.address_book_id - WHERE c.id = $1 AND c.user_id = $2`, - [req.params.id, userId] - ); - if (!cur.rows.length) return res.status(404).json({ error: 'Contact not found' }); - const c = cur.rows[0]; - if (c.book_source === 'carddav') { - return res.status(403).json({ error: 'This contact is synced from CardDAV and is read-only' }); - } - - const newEmails = emails !== undefined ? emails : c.emails; - const newPhones = phones !== undefined ? phones : c.phones; - const newDisplay = displayName !== undefined ? displayName : c.display_name; - const newFirst = firstName !== undefined ? firstName : c.first_name; - const newLast = lastName !== undefined ? lastName : c.last_name; - const newOrg = organization !== undefined ? organization : c.organization; - const newNotes = notes !== undefined ? notes : c.notes; - const newPrimary = emails === undefined - ? c.primary_email - : (newEmails[0]?.value ? newEmails[0].value.toLowerCase().trim() : null); - - const vcard = generateVCard({ - uid: c.uid, - displayName: newDisplay, - firstName: newFirst, - lastName: newLast, - emails: newEmails, - phones: newPhones, - organization: newOrg, - notes: newNotes, - }); - const etag = crypto.createHash('md5').update(vcard).digest('hex'); - - const result = await query(` - UPDATE contacts SET - display_name = $1, first_name = $2, last_name = $3, - primary_email = $4, emails = $5, phones = $6, - organization = $7, notes = $8, - vcard = $9, etag = $10, updated_at = NOW(), - is_auto = false - WHERE id = $11 AND user_id = $12 - RETURNING id, uid, display_name, first_name, last_name, - primary_email, emails, phones, organization, notes, - is_auto, send_count, last_sent, etag, created_at, updated_at - `, [ - newDisplay || null, newFirst || null, newLast || null, - newPrimary, - JSON.stringify(newEmails), JSON.stringify(newPhones), - newOrg || null, newNotes || null, - vcard, etag, - req.params.id, userId, - ]); - - await bumpSyncToken(c.address_book_id); - res.json(result.rows[0]); + res.json(await updateContact(userId, req.params.id, draft)); } catch (err) { - if (err.code === '23505') return res.status(409).json({ error: 'A contact with that email already exists' }); - console.error('Contact update error:', err); - res.status(500).json({ error: 'Failed to update contact' }); + contactError(res, err, 'Failed to update contact'); } }); @@ -283,26 +250,9 @@ router.patch('/:id', async (req, res) => { router.delete('/:id', async (req, res) => { const userId = req.session.userId; try { - // Block deletion of CardDAV-synced (read-only) contacts; they reappear on next sync anyway. - const owner = await query( - `SELECT ab.source FROM contacts c JOIN address_books ab ON ab.id = c.address_book_id - WHERE c.id = $1 AND c.user_id = $2`, - [req.params.id, userId] - ); - if (!owner.rows.length) return res.status(404).json({ error: 'Contact not found' }); - if (owner.rows[0].source === 'carddav') { - return res.status(403).json({ error: 'This contact is synced from CardDAV and is read-only' }); - } - const result = await query( - 'DELETE FROM contacts WHERE id = $1 AND user_id = $2 RETURNING address_book_id', - [req.params.id, userId] - ); - if (!result.rows.length) return res.status(404).json({ error: 'Contact not found' }); - await bumpSyncToken(result.rows[0].address_book_id); - res.json({ ok: true }); + res.json(await deleteContact(userId, req.params.id)); } catch (err) { - console.error('Contact delete error:', err); - res.status(500).json({ error: 'Failed to delete contact' }); + contactError(res, err, 'Failed to delete contact'); } }); diff --git a/backend/src/routes/contacts.test.js b/backend/src/routes/contacts.test.js new file mode 100644 index 00000000..e13c183d --- /dev/null +++ b/backend/src/routes/contacts.test.js @@ -0,0 +1,319 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + createContact: vi.fn(), + updateContact: vi.fn(), + deleteContact: vi.fn(), +})); + +vi.mock('../services/db.js', () => ({ query: mocks.query })); +vi.mock('../middleware/auth.js', () => ({ requireAuth: vi.fn((req, res, next) => next()) })); +vi.mock('../services/carddavContactService.js', () => ({ + CARDDAV_CONTACT_ERROR_STATUS: { + ERR_CONTACT_VALIDATION: 400, + ERR_CONTACT_UID_MISMATCH: 400, + ERR_CONTACT_NOT_FOUND: 404, + ERR_ADDRESS_BOOK_NOT_FOUND: 404, + ERR_CONTACT_EXISTS: 409, + ERR_CARDDAV_CONFLICT: 409, + ERR_CARDDAV_FINAL_FENCE: 503, + ERR_CARDDAV_STALE_GENERATION: 503, + ERR_CARDDAV_AMBIGUOUS_WRITE: 409, + ERR_CARDDAV_PENDING_INTENT: 409, + ERR_CARDDAV_READ_ONLY: 403, + '23505': 409, + }, + createContact: mocks.createContact, + updateContact: mocks.updateContact, + deleteContact: mocks.deleteContact, +})); + +const { default: router } = await import('./contacts.js'); + +function handler(method, path) { + return router.stack + .find(layer => layer.route?.path === path && layer.route.methods[method]) + .route.stack.at(-1).handle; +} + +const listHandler = handler('get', '/'); +const getHandler = handler('get', '/:id'); +const createHandler = handler('post', '/'); +const updateHandler = handler('patch', '/:id'); +const deleteHandler = handler('delete', '/:id'); + +function response() { + return { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + }; +} + +function draft(overrides = {}) { + return { + displayName: 'Ada Lovelace', + firstName: 'Ada', + lastName: 'Lovelace', + emails: [{ value: 'ada@example.test', type: 'work' }], + phones: [], + organization: 'Analytical Engines', + notes: 'First programmer', + photoData: 'data:image/png;base64,AQID', + additionalFields: [{ id: 'site', kind: 'url', label: 'Website', value: 'https://example.test' }], + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('contact read routes', () => { + it('lists contacts with CardDAV sync, capability, Additional-field, photo, and conflict state', async () => { + const contact = { + id: 'contact-1', + sync_state: 'conflict', + remote_create_capability: 'allowed', + remote_update_capability: 'unknown', + remote_delete_capability: 'denied', + additional_fields: [{ id: 'site', kind: 'url', value: 'https://example.test' }], + has_photo: true, + conflict_id: 'conflict-1', + read_only: false, + }; + mocks.query + .mockResolvedValueOnce({ rows: [contact] }) + .mockResolvedValueOnce({ rows: [{ count: '1' }] }); + const res = response(); + + await listHandler({ session: { userId: 'user-1' }, query: {} }, res); + + const sql = mocks.query.mock.calls[0][0]; + expect(sql).toContain('carddav_remote_objects'); + expect(sql).toContain('carddav_conflicts'); + expect(sql).toContain('remote_update_capability'); + expect(sql).not.toContain("(ab.source = 'carddav') AS read_only"); + expect(res.json).toHaveBeenCalledWith({ contacts: [contact], total: 1 }); + }); + + it('gets the same CardDAV state projection without mutating it', async () => { + const contact = { + id: 'contact-1', + sync_state: 'synced', + remote_update_capability: 'allowed', + remote_delete_capability: 'allowed', + additional_fields: [], + photo_data: null, + has_photo: false, + conflict_id: null, + read_only: false, + }; + mocks.query.mockResolvedValueOnce({ rows: [contact] }); + const res = response(); + + await getHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res); + + const sql = mocks.query.mock.calls[0][0]; + expect(sql).toContain('carddav_remote_objects'); + expect(sql).toContain('carddav_conflicts'); + expect(sql).not.toMatch(/\bc\.vcard\b/); + expect(sql).not.toMatch(/\b(?:INSERT|UPDATE|DELETE)\b/); + expect(res.json).toHaveBeenCalledWith(contact); + expect(res.json.mock.calls[0][0]).toBe(contact); + }); +}); + +describe('POST /api/contacts', () => { + it('delegates exactly once and preserves local-only response behavior', async () => { + const body = draft(); + const created = { id: 'contact-1', ...body }; + mocks.createContact.mockResolvedValueOnce(created); + const res = response(); + + await createHandler({ session: { userId: 'user-1' }, body }, res); + + expect(mocks.createContact).toHaveBeenCalledTimes(1); + expect(mocks.createContact).toHaveBeenCalledWith('user-1', body); + expect(mocks.query).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(created); + }); + + it.each([ + ['emails', draft({ emails: 'not-an-array' }), 'emails must be an array'], + ['phones', draft({ phones: 'not-an-array' }), 'phones must be an array'], + ['Additional fields', draft({ additionalFields: {} }), 'additionalFields must be an array'], + ['photo MIME', draft({ photoData: 'data:image/gif;base64,AQID' }), 'photoData must be a JPEG or PNG data URI'], + ['photo encoding', draft({ photoData: 'data:image/png;base64,***' }), 'photoData must contain valid base64 data'], + ['photo size', draft({ photoData: `data:image/jpeg;base64,${Buffer.alloc(512 * 1024 + 1).toString('base64')}` }), 'photoData must not exceed 512 KiB'], + ])('rejects invalid %s before delegation', async (_case, body, error) => { + const res = response(); + + await createHandler({ session: { userId: 'user-1' }, body }, res); + + expect(mocks.createContact).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error }); + }); +}); + +describe('PATCH /api/contacts/:id', () => { + it('delegates a writable mapped contact exactly once', async () => { + const body = draft({ displayName: 'Ada Byron' }); + const updated = { id: 'contact-1', ...body }; + mocks.updateContact.mockResolvedValueOnce(updated); + const res = response(); + + await updateHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' }, body }, res); + + expect(mocks.updateContact).toHaveBeenCalledTimes(1); + expect(mocks.updateContact).toHaveBeenCalledWith('user-1', 'contact-1', body); + expect(mocks.query).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith(updated); + }); + + it('returns 403 when the operation is denied', async () => { + mocks.updateContact.mockRejectedValueOnce(Object.assign( + new Error('This CardDAV address book does not allow update'), + { code: 'ERR_CARDDAV_READ_ONLY' }, + )); + const res = response(); + + await updateHandler({ + session: { userId: 'user-1' }, + params: { id: 'contact-1' }, + body: { displayName: 'Ada Byron' }, + }, res); + + expect(mocks.updateContact).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'This CardDAV address book does not allow update' }); + }); + + it('attempts an unknown-capability operation and reports its upstream denial', async () => { + mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error('Forbidden'), { status: 403 })); + const res = response(); + + await updateHandler({ + session: { userId: 'user-1' }, + params: { id: 'contact-1' }, + body: { displayName: 'Ada Byron' }, + }, res); + + expect(mocks.updateContact).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }); + }); + + it('returns the durable conflict ID for a stale write', async () => { + mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error('conflict'), { + code: 'ERR_CARDDAV_CONFLICT', + conflictId: 'conflict-1', + })); + const res = response(); + + await updateHandler({ + session: { userId: 'user-1' }, + params: { id: 'contact-1' }, + body: { displayName: 'Ada Byron' }, + }, res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ conflictId: 'conflict-1' }); + }); + + it.each([ + ['ERR_CARDDAV_FINAL_FENCE', 'CardDAV mapping changed after the remote write'], + ['ERR_CARDDAV_STALE_GENERATION', 'The CardDAV connection changed before export'], + ])('maps the self-healing %s race to a retriable 503', async (code, message) => { + mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error(message), { code })); + const res = response(); + + await updateHandler({ + session: { userId: 'user-1' }, + params: { id: 'contact-1' }, + body: { displayName: 'Ada Byron' }, + }, res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ error: message, code, retriable: true }); + }); + + it.each([ + ['ERR_CARDDAV_AMBIGUOUS_WRITE', 'The CardDAV write succeeded, but MailFlow could not confirm its local state'], + ['ERR_CARDDAV_PENDING_INTENT', 'A CardDAV mutation is already awaiting confirmation'], + ])('maps the post-write %s to a non-retriable 409 that tells the client to refresh', async (code, message) => { + mocks.updateContact.mockRejectedValueOnce(Object.assign(new Error(message), { code })); + const res = response(); + + await updateHandler({ + session: { userId: 'user-1' }, + params: { id: 'contact-1' }, + body: { displayName: 'Ada Byron' }, + }, res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ error: message, code, refresh: true }); + // The remote effect may have landed; never invite a blind re-issue of the mutation. + expect(res.json.mock.calls[0][0]).not.toHaveProperty('retriable', true); + }); + + it('accepts an explicit photo removal', async () => { + mocks.updateContact.mockResolvedValueOnce({ id: 'contact-1', photo_data: null }); + const res = response(); + + await updateHandler({ + session: { userId: 'user-1' }, + params: { id: 'contact-1' }, + body: { photoData: null, additionalFields: [] }, + }, res); + + expect(mocks.updateContact).toHaveBeenCalledWith('user-1', 'contact-1', { + photoData: null, + additionalFields: [], + }); + expect(res.json).toHaveBeenCalledWith({ id: 'contact-1', photo_data: null }); + }); +}); + +describe('DELETE /api/contacts/:id', () => { + it('delegates exactly once and preserves the local-only response', async () => { + mocks.deleteContact.mockResolvedValueOnce({ ok: true }); + const res = response(); + + await deleteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res); + + expect(mocks.deleteContact).toHaveBeenCalledTimes(1); + expect(mocks.deleteContact).toHaveBeenCalledWith('user-1', 'contact-1'); + expect(mocks.query).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + }); + + it('returns 403 when deletion is denied', async () => { + mocks.deleteContact.mockRejectedValueOnce(Object.assign( + new Error('This CardDAV address book does not allow delete'), + { code: 'ERR_CARDDAV_READ_ONLY' }, + )); + const res = response(); + + await deleteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res); + + expect(mocks.deleteContact).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it.each([ + ['ERR_CARDDAV_AMBIGUOUS_WRITE', 'The CardDAV write succeeded, but MailFlow could not confirm its local state'], + ['ERR_CARDDAV_PENDING_INTENT', 'A CardDAV mutation is already awaiting confirmation'], + ])('maps a post-write %s delete to a non-retriable 409 that tells the client to refresh', async (code, message) => { + mocks.deleteContact.mockRejectedValueOnce(Object.assign(new Error(message), { code })); + const res = response(); + + await deleteHandler({ session: { userId: 'user-1' }, params: { id: 'contact-1' } }, res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ error: message, code, refresh: true }); + expect(res.json.mock.calls[0][0]).not.toHaveProperty('retriable', true); + }); +}); diff --git a/backend/src/services/carddavClient.js b/backend/src/services/carddavClient.js index af1ed103..fa55a24c 100644 --- a/backend/src/services/carddavClient.js +++ b/backend/src/services/carddavClient.js @@ -1,118 +1,238 @@ -// Minimal CardDAV *client* — discovers address books on a remote server (e.g. -// Nextcloud) and pulls vCards. One-way/read-only: we never write back. +// CardDAV client — discovers address books, pulls vCards, and performs +// conditional resource writes against a remote server (e.g. Nextcloud). // // Flow: current-user-principal -> addressbook-home-set -> enumerate collections -// -> addressbook-query REPORT for each book's vCards. Uses native fetch with the -// WebDAV verbs PROPFIND/REPORT and HTTP Basic auth. Host is SSRF-validated up -// front (reusing the same policy IMAP/SMTP hosts use). - -import { XMLParser } from 'fast-xml-parser'; -import { validateHost } from './hostValidation.js'; -import { safeFetch } from './safeFetch.js'; - -const parser = new XMLParser({ - ignoreAttributes: false, - removeNSPrefix: true, // -> response, so parsing is namespace-agnostic - trimValues: false, // preserve vCard line structure inside - // Large CardDAV REPORTs can exceed fast-xml-parser's 1000-expansion default. - // Raise it generously while preserving the previous depth setting. - processEntities: { maxTotalExpansions: 10_000_000, maxExpansionDepth: 10 }, -}); - -const toArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]); - -function basicAuth(username, password) { - return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); -} - -async function assertHostAllowed(url, allowPrivate) { - let hostname; - try { hostname = new URL(url).hostname; } - catch { throw new Error('Invalid server URL'); } - const err = await validateHost(hostname, { allowPrivate }); - if (err) throw new Error(err); -} - -async function dav(method, url, { username, password, depth, body, allowPrivate = false } = {}) { - // Re-validate on every request: hrefs returned by the server (principal, home - // set, book URLs) are attacker-influenced and could point at internal hosts. - await assertHostAllowed(url, allowPrivate); - const headers = { - Authorization: basicAuth(username, password), - 'Content-Type': 'application/xml; charset=utf-8', - }; - if (depth != null) headers.Depth = String(depth); - let res; - try { - // safeFetch validates every redirect hop's IP (well-known discovery relies on - // the server's 301 redirect), honouring the admin private-host policy. - res = await safeFetch(url, { method, headers, body, redirect: 'follow', signal: AbortSignal.timeout(30000) }, { allowPrivate }); - } catch (err) { - if (err.name === 'TimeoutError') throw new Error('CardDAV server did not respond (timed out)', { cause: err }); - throw new Error(`Could not reach the CardDAV server: ${err.message}`, { cause: err }); - } - if (res.status === 401) throw new Error('Authentication failed — check the username and app password'); - if (!res.ok && res.status !== 207) { - throw new Error(`CardDAV request failed (${res.status} ${res.statusText})`); - } - return res.text(); -} - -// Merge the blocks from every 2xx propstat of a into one object. -// A propstat carrying a non-2xx status (e.g. 404 for unsupported props) is skipped; -// a propstat with no status line at all is treated as usable. -function propsOf(response) { - const merged = {}; - for (const ps of toArray(response.propstat)) { - const status = typeof ps.status === 'string' ? ps.status : ''; - if (status && !/\b2\d\d\b/.test(status)) continue; - Object.assign(merged, ps.prop || {}); - } - return merged; -} - -function textOf(node) { - if (node == null) return ''; - if (typeof node === 'string') return node; - if (typeof node === 'object' && '#text' in node) return String(node['#text']); - return ''; -} - -// fast-xml-parser decodes named XML entities (& -> &) but leaves numeric -// character references ( , é) as literal text. Nextcloud/SabreDAV encodes -// each vCard line's trailing CR as inside so the CRLF endings -// survive XML line-ending normalization; without decoding, every parsed field keeps -// a literal " " (and an empty property renders as just " "). Decode decimal -// and hex references back to their characters — the vCard parser then handles the -// restored CR/LF normally. Named entities are left for the XML parser to resolve. -function decodeXmlCharRefs(str) { - return str.replace(/&#([xX][0-9a-fA-F]+|\d+);/g, (match, code) => { - const cp = (code[0] === 'x' || code[0] === 'X') - ? parseInt(code.slice(1), 16) - : parseInt(code, 10); - // Reject out-of-range and surrogate code points; leave those references as-is. - if (!Number.isFinite(cp) || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) return match; - try { return String.fromCodePoint(cp); } - catch { return match; } +// -> addressbook-query REPORT for each book's vCards. This module owns CardDAV +// protocol logic; HTTP transport, response limits, and SSRF fencing live in +// carddavTransport.js. + +import { + CardDavError, + createDavOperation, + davRequest, +} from './carddavTransport.js'; +import { + CARDDAV_NS, + DAV_NS, + childrenNamed, + onlyChildNamed, + parseDavMultistatus, + parseDavResponse, + successfulProperties, + textOfNode, + xmlEscape, +} from './carddavXml.js'; + +function normalizePercentEscapes(value) { + return value.replace(/%([0-9a-f]{2})/gi, (match, hex) => { + const byte = Number.parseInt(hex, 16); + const character = String.fromCharCode(byte); + return /[A-Za-z0-9\-._~]/.test(character) + ? character + : `%${hex.toUpperCase()}`; }); } -// Resolve an href (often an absolute path) against the request URL's origin. -function absolute(href, baseUrl) { - try { return new URL(href, baseUrl).href; } - catch { return href; } +export function canonicalCollectionUrl(rawUrl, baseUrl) { + let url; + try { + url = new URL(rawUrl, baseUrl); + } catch { + throw new CardDavError('CardDAV collection URL was invalid'); + } + if (!['http:', 'https:'].includes(url.protocol) + || url.username || url.password || url.hash) { + throw new CardDavError('CardDAV collection URL must be HTTP(S) without credentials or a fragment'); + } + const pathname = normalizePercentEscapes(url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`); + const search = normalizePercentEscapes(url.search); + return `${url.origin}${pathname}${search}`; +} + +function recordCollectionIdentity(identity, requestUrl) { + const canonicalUrl = canonicalCollectionUrl(requestUrl); + if (identity.canonicalUrl && identity.canonicalUrl !== canonicalUrl) { + throw new CardDavError('CardDAV operation changed collection identity'); + } + identity.canonicalUrl = canonicalUrl; +} + +const DAV_MAX_DISCOVERY_RESPONSES = 1_000; +const DAV_MAX_SYNC_PAGES = 100; +const DAV_MAX_SYNC_MEMBERS = 50_000; + +function withDavOperation(url, operation, callback) { + if (operation) return callback(operation); + const ownedOperation = createDavOperation(url); + return ownedOperation.run(() => callback(ownedOperation)); +} + +async function runCardResourceOperation(operationName, callback) { + try { + return await callback(); + } catch (error) { + if (error instanceof CardDavError) { + error.operation = operationName; + throw error; + } + throw new CardDavError(error.message, { operation: operationName, cause: error }); + } +} + +function hrefScopeError(message) { + return Object.assign(new CardDavError(message), { code: 'ERR_DAV_HREF_SCOPE' }); +} + +function validateResourceRedirect(collectionUrl) { + return redirectUrl => memberHref(redirectUrl, collectionUrl); +} + +function resolveSameOriginHref(rawHref, baseUrl) { + if (typeof rawHref !== 'string' || !rawHref || rawHref !== rawHref.trim()) { + throw hrefScopeError('CardDAV href must be a non-empty URI reference'); + } + if (rawHref.includes('\\')) { + throw hrefScopeError('CardDAV href must not contain backslashes'); + } + if (rawHref.includes('#') || /^(?:[a-z][a-z\d+.-]*:)?\/\/[^/?#]*@/i.test(rawHref)) { + throw hrefScopeError('CardDAV href must not contain credentials or a fragment'); + } + let base; + let resolved; + try { + base = new URL(baseUrl); + resolved = new URL(rawHref, base); + } catch { + throw hrefScopeError('CardDAV href was not a valid URI reference'); + } + if (resolved.username || resolved.password || resolved.hash) { + throw hrefScopeError('CardDAV href must not contain credentials or a fragment'); + } + if (resolved.origin !== base.origin) { + throw hrefScopeError('CardDAV href must stay on the credential origin'); + } + return resolved.href; } -// Pure: pull a single href-valued property out of a PROPFIND multistatus, by its -// namespace-stripped local name (e.g. 'current-user-principal'). Exported for testing. +export function memberHref(rawHref, collectionUrl, { allowCollection = false } = {}) { + const rawPath = typeof rawHref === 'string' ? rawHref.split(/[?#]/, 1)[0] : ''; + if (/(?:^|\/)(?:\.|%2e){2}(?:\/|$)/i.test(rawPath) || rawPath.includes('\\')) { + throw hrefScopeError('CardDAV member href escaped its collection'); + } + + const href = resolveSameOriginHref(rawHref, collectionUrl); + const collection = new URL(collectionUrl); + const member = new URL(href); + if (href === collection.href) { + if (allowCollection) return href; + throw hrefScopeError('CardDAV member href identified the collection itself'); + } + if (!collection.pathname.endsWith('/') || !member.pathname.startsWith(collection.pathname)) { + throw hrefScopeError('CardDAV member href was outside its collection'); + } + + const relativePath = member.pathname.slice(collection.pathname.length); + const childName = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; + if (!childName || childName.includes('/') || childName.includes('\\') + || /%(?:2f|5c)/i.test(childName)) { + throw hrefScopeError('CardDAV member href was not a direct collection child'); + } + return href; +} + +function nodesNamed(nodes, namespaceURI, localName) { + return nodes.filter(node => ( + node.namespaceURI === namespaceURI && node.localName === localName + )); +} + +function optionalProperty(properties, namespaceURI, localName, label) { + const matches = nodesNamed(properties, namespaceURI, localName); + if (matches.length > 1) { + throw new Error(`${label} contained duplicate {${namespaceURI}}${localName} properties`); + } + return matches[0] ?? null; +} + +function optionalAttribute(node, namespaceURI, localName, label) { + const matches = (node.attributes || []).filter(attribute => ( + attribute.namespaceURI === namespaceURI && attribute.localName === localName + )); + if (matches.length > 1) { + throw new Error(`${label} contained duplicate {${namespaceURI ?? ''}}${localName} attributes`); + } + return matches[0]?.value ?? null; +} + +function discoveryProperties(response, label, requiredProperty) { + if (response.status != null) { + throw new CardDavError(`${label} failed (${response.status})`, { status: response.status }); + } + for (const propstat of response.propstats) { + if (propstat.status >= 200 && propstat.status < 300) continue; + const failedRequired = propstat.properties.some(node => ( + node.namespaceURI === requiredProperty.namespaceURI + && node.localName === requiredProperty.localName + )); + if (propstat.status !== 404 || failedRequired) { + throw new CardDavError(`${label} failed (${propstat.status})`, { status: propstat.status }); + } + } + const properties = successfulProperties(response); + if (!optionalProperty( + properties, + requiredProperty.namespaceURI, + requiredProperty.localName, + label, + )) { + throw new Error( + `${label} did not include a successful {${requiredProperty.namespaceURI}}${requiredProperty.localName}`, + ); + } + return properties; +} + +// Pure: pull one exact href-valued property out of a PROPFIND multistatus. +// `key` is mapped to its required DAV/CardDAV namespace. Exported for testing. export function extractHref(xmlText, key, baseUrl) { - const xml = parser.parse(xmlText); - const response = toArray(xml?.multistatus?.response)[0]; - if (!response) return null; - const val = propsOf(response)[key]; - const href = val?.href ?? val; - const text = textOf(href) || (typeof href === 'string' ? href : ''); - return text ? absolute(text, baseUrl) : null; + const expectedNamespace = key === 'current-user-principal' ? DAV_NS + : key === 'addressbook-home-set' ? CARDDAV_NS : null; + if (!expectedNamespace) throw new TypeError(`Unsupported CardDAV discovery property: ${key}`); + + const multistatus = parseDavMultistatus(xmlText, 'discovery response'); + const responseNodes = childrenNamed(multistatus, DAV_NS, 'response'); + if (responseNodes.length !== 1) { + throw new Error(`CardDAV discovery response must contain exactly one DAV response; found ${responseNodes.length}`); + } + const response = parseDavResponse(responseNodes[0], 'CardDAV discovery response'); + resolveSameOriginHref(response.href, baseUrl); + if (response.status != null) { + if (response.status === 404) return null; + throw new CardDavError(`CardDAV discovery response failed (${response.status})`, { + status: response.status, + }); + } + for (const propstat of response.propstats) { + if ((propstat.status < 200 || propstat.status >= 300) && propstat.status !== 404) { + throw new CardDavError(`CardDAV discovery response failed (${propstat.status})`, { + status: propstat.status, + }); + } + } + const property = optionalProperty( + successfulProperties(response), + expectedNamespace, + key, + 'CardDAV discovery response', + ); + if (!property) return null; + const href = textOfNode(onlyChildNamed( + property, + DAV_NS, + 'href', + `{${expectedNamespace}}${key}`, + )); + return resolveSameOriginHref(href, baseUrl); } // PROPFIND for a single href-valued property. `key` is the expected local name in @@ -120,7 +240,12 @@ export function extractHref(xmlText, key, baseUrl) { async function propfindHref(url, propXml, key, creds) { const body = ` ${propXml}`; - return extractHref(await dav('PROPFIND', url, { ...creds, depth: 0, body }), key, url); + const { bodyText, requestUrl } = await davRequest(creds.operation, 'PROPFIND', url, { + ...creds, + depth: 0, + body, + }); + return extractHref(bodyText, key, requestUrl); } // Find the user's principal URL. Tries the given URL, then RFC 6764 well-known @@ -135,7 +260,9 @@ async function resolvePrincipal(serverUrl, creds) { const principal = await propfindHref(base, '', 'current-user-principal', creds); if (principal) return principal; } catch (err) { - if (/Authentication failed/.test(err.message)) throw err; // wrong creds — stop trying + if (err instanceof CardDavError && (err.status === 401 || err.code === 'ERR_DAV_HREF_SCOPE')) { + throw err; + } lastErr = err; } } @@ -144,73 +271,680 @@ async function resolvePrincipal(serverUrl, creds) { } // Discover every address book on the server for these credentials. -// Returns [{ url, displayName }]. +// Returns [{ url, displayName, supportsSyncCollection }]. export async function discoverAddressBooks({ serverUrl, username, password, allowPrivate = false }) { - await assertHostAllowed(serverUrl, allowPrivate); - const creds = { username, password, allowPrivate }; + const operation = createDavOperation(new URL(serverUrl).origin); + return operation.run(async () => { + const creds = { username, password, allowPrivate, operation }; - const principal = await resolvePrincipal(serverUrl, creds); - const homeSet = await propfindHref(principal, '', 'addressbook-home-set', creds) - || principal; + const principal = await resolvePrincipal(serverUrl, creds); + const homeSet = await propfindHref(principal, '', 'addressbook-home-set', creds) + || principal; - // Enumerate collections under the home set (Depth: 1). - const body = ` - - `; - const xmlText = await dav('PROPFIND', homeSet, { ...creds, depth: 1, body }); - const books = parseAddressBooks(xmlText, homeSet); - if (!books.length) throw new Error('No address books found for this account'); - return books; + // Enumerate collections under the home set (Depth: 1). + const body = ` + + + `; + const result = await davRequest(operation, 'PROPFIND', homeSet, { ...creds, depth: 1, body }); + const books = parseAddressBooks(result.bodyText, result.requestUrl); + return books; + }); } // Pure: extract address-book collections from a PROPFIND multistatus. Exported -// for testing. Returns [{ url, displayName }]. +// for testing. Returns [{ url, displayName, supportsSyncCollection, +// capabilities, discoveryIndex, addressData }]. export function parseAddressBooks(xmlText, baseUrl) { - const xml = parser.parse(xmlText); + const multistatus = parseDavMultistatus(xmlText, 'address book discovery response'); + const responseNodes = childrenNamed(multistatus, DAV_NS, 'response'); + if (responseNodes.length > DAV_MAX_DISCOVERY_RESPONSES) { + throw new Error( + `CardDAV discovery exceeded the ${DAV_MAX_DISCOVERY_RESPONSES.toLocaleString('en-US')} response limit`, + ); + } const books = []; - for (const response of toArray(xml?.multistatus?.response)) { - const props = propsOf(response); - const rt = props.resourcetype || {}; - if (!('addressbook' in rt)) continue; // only address book collections - const href = textOf(response.href) || response.href; - if (!href) continue; - books.push({ - url: absolute(href, baseUrl), - displayName: textOf(props.displayname) || 'Contacts', + const byUrl = new Map(); + const collectionUrl = new URL(baseUrl).href; + let foundCollection = false; + for (const [index, responseNode] of responseNodes.entries()) { + const label = `CardDAV discovery response ${index + 1}`; + const response = parseDavResponse(responseNode, label); + const href = memberHref(response.href, collectionUrl, { allowCollection: true }); + const canonicalUrl = canonicalCollectionUrl(href); + const properties = discoveryProperties(response, label, { + namespaceURI: DAV_NS, + localName: 'resourcetype', }); + const resourceType = optionalProperty(properties, DAV_NS, 'resourcetype', label); + if (resourceType.children.some(node => ( + node.localName === 'addressbook' && node.namespaceURI !== CARDDAV_NS + ))) { + throw new Error(`${label} used a foreign addressbook namespace`); + } + const isCollection = childrenNamed(resourceType, DAV_NS, 'collection').length > 0; + const isAddressBook = childrenNamed(resourceType, CARDDAV_NS, 'addressbook').length > 0; + + if (canonicalUrl === canonicalCollectionUrl(collectionUrl)) { + if (!isCollection) throw new Error(`${label} did not identify the DAV home collection`); + if (foundCollection) throw new Error('CardDAV discovery returned duplicate home collection self responses'); + foundCollection = true; + continue; + } + if (!isAddressBook) continue; + if (!isCollection) throw new Error(`${label} did not identify a DAV address book collection`); + const displayName = optionalProperty(properties, DAV_NS, 'displayname', label); + const book = { + url: canonicalUrl, + displayName: textOfNode(displayName) || 'Contacts', + supportsSyncCollection: supportedReportsOf(properties, label).syncCollection, + capabilities: capabilitiesOf(properties, label), + discoveryIndex: books.length, + addressData: addressDataOf(properties, label), + }; + const existing = byUrl.get(canonicalUrl); + if (existing) { + if (existing.displayName !== book.displayName + || existing.supportsSyncCollection !== book.supportsSyncCollection + || !sameCapabilities(existing.capabilities, book.capabilities) + || !sameAddressData(existing.addressData, book.addressData)) { + throw new CardDavError(`CardDAV discovery returned conflicting metadata for ${canonicalUrl}`); + } + continue; + } + byUrl.set(canonicalUrl, book); + books.push(book); + } + if (!foundCollection) { + throw new Error('CardDAV discovery did not include the required home collection self response'); } return books; } -// Fetch every vCard in an address book via a filter-less addressbook-query REPORT. +// Pure: detect the incremental REPORT methods advertised by a collection. +export function parseSupportedReports(xmlText) { + const multistatus = parseDavMultistatus(xmlText, 'supported report discovery response'); + let syncCollection = false; + let addressbookMultiget = false; + for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) { + const label = `CardDAV supported report response ${index + 1}`; + const response = parseDavResponse(responseNode, label); + if (response.status != null) { + throw new CardDavError(`${label} failed (${response.status})`, { status: response.status }); + } + const reports = supportedReportsOf(successfulProperties(response), label); + syncCollection ||= reports.syncCollection; + addressbookMultiget ||= reports.addressbookMultiget; + } + return { syncCollection, addressbookMultiget }; +} + +function supportedReportsOf(properties, label) { + let syncCollection = false; + let addressbookMultiget = false; + const supported = optionalProperty(properties, DAV_NS, 'supported-report-set', label); + if (!supported) return { syncCollection, addressbookMultiget }; + for (const item of childrenNamed(supported, DAV_NS, 'supported-report')) { + const report = onlyChildNamed(item, DAV_NS, 'report', `${label} supported-report`); + syncCollection ||= childrenNamed(report, DAV_NS, 'sync-collection').length > 0; + addressbookMultiget ||= childrenNamed(report, CARDDAV_NS, 'addressbook-multiget').length > 0; + } + return { syncCollection, addressbookMultiget }; +} + +function capabilitiesOf(properties, label) { + const current = optionalProperty(properties, DAV_NS, 'current-user-privilege-set', label); + if (!current) { + return { create: 'unknown', update: 'unknown', delete: 'unknown' }; + } + + const granted = new Set(); + for (const privilegeNode of childrenNamed(current, DAV_NS, 'privilege')) { + if (privilegeNode.children.length !== 1) { + throw new Error(`${label} contained a malformed DAV privilege`); + } + const [privilege] = privilegeNode.children; + if (privilege.namespaceURI !== DAV_NS) continue; + granted.add(privilege.localName); + if (privilege.localName === 'write' || privilege.localName === 'all') { + granted.add('bind'); + granted.add('write-content'); + granted.add('unbind'); + } + } + return { + create: granted.has('bind') ? 'allowed' : 'denied', + update: granted.has('write-content') ? 'allowed' : 'denied', + delete: granted.has('unbind') ? 'allowed' : 'denied', + }; +} + +function addressDataOf(properties, label) { + const supported = optionalProperty(properties, CARDDAV_NS, 'supported-address-data', label); + if (!supported) return []; + return childrenNamed(supported, CARDDAV_NS, 'address-data-type').map((node, index) => { + const entryLabel = `${label} supported address data ${index + 1}`; + return { + contentType: optionalAttribute(node, null, 'content-type', entryLabel) ?? 'text/vcard', + version: optionalAttribute(node, null, 'version', entryLabel) ?? '3.0', + }; + }); +} + +function sameCapabilities(left, right) { + return left.create === right.create + && left.update === right.update + && left.delete === right.delete; +} + +function sameAddressData(left, right) { + return left.length === right.length && left.every((entry, index) => ( + entry.contentType === right[index].contentType && entry.version === right[index].version + )); +} + +// Pure: build an RFC 6578 sync-collection REPORT body. +export function buildSyncCollectionBody(syncToken) { + return ` +${xmlEscape(syncToken)} + 1`; +} + +// Pure: parse one RFC 6578 sync-collection response page. +export function parseSyncPage(xmlText, baseUrl) { + const multistatus = parseDavMultistatus(xmlText, 'sync response'); + const collectionUrl = new URL(baseUrl).href; + const events = new Map(); + let truncated = false; + + for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) { + const label = `CardDAV sync response ${index + 1}`; + const response = parseDavResponse(responseNode, label); + + if (response.status != null) { + const href = memberHref(response.href, collectionUrl, { + allowCollection: response.status === 507, + }); + if (response.status === 507 && href === collectionUrl) { + truncated = true; + continue; + } + if (response.status === 404) { + if (events.has(href)) { + throw new Error(`CardDAV sync response contained duplicate member href ${href}`); + } + events.set(href, { type: 'removed', href }); + continue; + } + if (response.status < 200 || response.status >= 300) { + throw new CardDavError(`CardDAV sync failed for ${href} (${response.status})`, { + status: response.status, + }); + } + throw new Error(`${label} did not include requested sync properties`); + } + + const failedPropstat = response.propstats.find(({ status }) => status < 200 || status >= 300); + const href = memberHref(response.href, collectionUrl, { allowCollection: Boolean(failedPropstat) }); + if (failedPropstat) { + throw new CardDavError(`CardDAV sync failed for ${href} (${failedPropstat.status})`, { + status: failedPropstat.status, + }); + } + if (events.has(href)) { + throw new Error(`CardDAV sync response contained duplicate member href ${href}`); + } + const etag = optionalProperty(successfulProperties(response), DAV_NS, 'getetag', label); + if (!etag) { + throw new Error(`${label} did not include a successful DAV getetag`); + } + events.set(href, { + type: 'changed', + href, + etag: textOfNode(etag), + }); + } + + const tokenNodes = childrenNamed(multistatus, DAV_NS, 'sync-token'); + const nextToken = tokenNodes.length === 1 ? textOfNode(tokenNodes[0]) : ''; + if (tokenNodes.length !== 1 || !nextToken.trim()) { + if (truncated) { + throw new Error('CardDAV sync response was truncated without a continuation token'); + } + throw new Error('CardDAV sync response did not include a usable sync token'); + } + return { + changed: [...events.values()] + .filter(event => event.type === 'changed') + .map(({ href, etag: eventEtag }) => ({ href, etag: eventEtag })), + removed: [...events.values()] + .filter(event => event.type === 'removed') + .map(({ href }) => ({ href })), + nextToken, + truncated, + }; +} + +// Pure: build an RFC 6352 addressbook-multiget REPORT body. +export function buildMultigetBody(hrefs) { + const requested = hrefs.map(href => `${xmlEscape(href)}`).join(''); + return ` + + ${requested}`; +} + +// Fetch one RFC 6578 page without opening any database transaction. +export async function fetchSyncPage({ + url, + syncToken, + username, + password, + allowPrivate = false, + operation, + identity, +}) { + return withDavOperation(url, operation, async activeOperation => { + const body = buildSyncCollectionBody(syncToken); + const { bodyText, requestUrl } = await davRequest(activeOperation, 'REPORT', url, { + username, + password, + depth: 0, + body, + allowPrivate, + }); + if (identity) recordCollectionIdentity(identity, requestUrl); + return parseSyncPage(bodyText, requestUrl); + }); +} + +// Fetch one RFC 6352 multiget batch without opening any database transaction. +export async function fetchCardsByHref({ + url, + hrefs, + username, + password, + allowPrivate = false, + operation, + identity, +}) { + if (!Array.isArray(hrefs) || hrefs.length === 0) { + throw new CardDavError('CardDAV multiget requires a non-empty href list'); + } + const collectionUrl = identity?.canonicalUrl || url; + const normalizedHrefs = hrefs.map(href => memberHref(href, collectionUrl)); + const requestedHrefs = new Set(normalizedHrefs); + if (requestedHrefs.size !== normalizedHrefs.length) { + throw new CardDavError('CardDAV multiget hrefs must be unique after normalization'); + } + + return withDavOperation(url, operation, async activeOperation => { + const body = buildMultigetBody(normalizedHrefs); + const { bodyText, requestUrl } = await davRequest(activeOperation, 'REPORT', url, { + username, + password, + depth: 0, + body, + allowPrivate, + }); + if (identity) recordCollectionIdentity(identity, requestUrl); + const results = parseMultigetCards(bodyText, requestUrl); + const resultCounts = new Map(); + for (const result of results) { + resultCounts.set(result.href, (resultCounts.get(result.href) || 0) + 1); + } + for (const returnedHref of resultCounts.keys()) { + if (!requestedHrefs.has(returnedHref)) { + throw new CardDavError(`CardDAV multiget returned an unrequested response for ${returnedHref}`); + } + } + for (const requestedHref of requestedHrefs) { + const count = resultCounts.get(requestedHref) || 0; + if (count !== 1) { + throw new CardDavError( + `CardDAV multiget returned ${count} terminal responses for ${requestedHref}`, + ); + } + } + return results; + }); +} + +async function fetchSnapshotPlan({ url, syncToken, identity, ...creds }) { + return { + expectedRemoteToken: syncToken ?? null, + nextRemoteToken: null, + capability: 'snapshot', + replaceAll: true, + upserts: await fetchAddressBookCards({ url, identity, ...creds }), + removedHrefs: [], + }; +} + +async function fetchSyncPlan({ url, syncToken, identity, ...creds }) { + const events = new Map(); + const seenContinuationTokens = new Set(); + let pageToken = syncToken ?? ''; + let page; + let pageCount = 0; + + while (true) { + if (pageCount === DAV_MAX_SYNC_PAGES) { + throw new CardDavError(`CardDAV sync exceeded the ${DAV_MAX_SYNC_PAGES} page limit`); + } + seenContinuationTokens.add(pageToken); + page = await fetchSyncPage({ url, syncToken: pageToken, identity, ...creds }); + pageCount++; + for (const [eventList, disposition] of [ + [page.changed, 'changed'], + [page.removed, 'removed'], + ]) { + for (const event of eventList) { + if (!events.has(event.href)) { + if (events.size === DAV_MAX_SYNC_MEMBERS) { + throw new CardDavError( + `CardDAV sync exceeded the ${DAV_MAX_SYNC_MEMBERS.toLocaleString('en-US')} member limit`, + ); + } + } + events.set(event.href, disposition); + } + } + if (!page.truncated) break; + if (seenContinuationTokens.has(page.nextToken)) { + throw new CardDavError('CardDAV sync continuation token cycle detected'); + } + pageToken = page.nextToken; + } + + const changedHrefs = [...events] + .filter(([, disposition]) => disposition === 'changed') + .map(([href]) => href); + const upserts = []; + for (let offset = 0; offset < changedHrefs.length; offset += 100) { + const cards = await fetchCardsByHref({ + url, + hrefs: changedHrefs.slice(offset, offset + 100), + identity, + ...creds, + }); + for (const card of cards) { + if (card.status === 404) events.set(card.href, 'removed'); + else upserts.push(card); + } + } + + return { + expectedRemoteToken: syncToken ?? null, + nextRemoteToken: page.nextToken, + capability: 'sync-collection', + replaceAll: syncToken == null || syncToken === '', + upserts, + removedHrefs: [...events] + .filter(([, disposition]) => disposition === 'removed') + .map(([href]) => href), + }; +} + +// Build one complete network plan before callers perform any database write. +export async function fetchAddressBookDelta({ + url, + syncToken, + supportsSyncCollection, + ...creds +}) { + const observedUrl = canonicalCollectionUrl(url); + const identity = { canonicalUrl: null }; + const operation = createDavOperation(url); + return operation.run(async () => { + const operationCreds = { ...creds, operation }; + let plan; + if (!supportsSyncCollection) { + plan = await fetchSnapshotPlan({ url, syncToken, identity, ...operationCreds }); + } else { + try { + plan = await fetchSyncPlan({ url, syncToken, identity, ...operationCreds }); + } catch (error) { + if (!(error instanceof CardDavError) + || (error.requestStatus !== 405 && error.requestStatus !== 501)) { + throw error; + } + plan = await fetchSnapshotPlan({ url, syncToken, identity, ...operationCreds }); + } + } + return { ...plan, collectionIdentity: { observedUrl, canonicalUrl: identity.canonicalUrl } }; + }); +} + +// Fetch every vCard in an address book via an addressbook-query REPORT. // Returns [{ href, etag, vcard }]. -export async function fetchAddressBookCards({ url, username, password, allowPrivate = false }) { - await assertHostAllowed(url, allowPrivate); - const body = ` - - `; - const xmlText = await dav('REPORT', url, { username, password, depth: 1, body, allowPrivate }); - return parseCards(xmlText, url); +export async function fetchAddressBookCards({ + url, + username, + password, + allowPrivate = false, + operation, + identity, +}) { + return withDavOperation(url, operation, async activeOperation => { + const body = ` + + + +`; + const { bodyText, requestUrl } = await davRequest(activeOperation, 'REPORT', url, { + username, + password, + depth: 1, + body, + allowPrivate, + }); + if (identity) recordCollectionIdentity(identity, requestUrl); + return parseCards(bodyText, requestUrl); + }); +} + +export async function fetchCardResource({ + url, + href, + username, + password, + allowPrivate = false, + operation, +}) { + return runCardResourceOperation('fetch', () => { + const resourceUrl = memberHref(href, url); + return withDavOperation(url, operation, async activeOperation => { + const result = await davRequest(activeOperation, 'GET', resourceUrl, { + username, + password, + headers: { Accept: 'text/vcard' }, + errorOperation: 'fetch', + validateRedirect: validateResourceRedirect(url), + allowPrivate, + }); + const finalHref = memberHref(result.requestUrl, url); + const etag = result.headers.get('etag'); + if (!etag || !result.bodyText.trim()) { + throw new CardDavError('CardDAV resource did not include a vCard and ETag', { + operation: 'fetch', + }); + } + return { href: finalHref, etag, vcard: result.bodyText }; + }); + }); +} + +export async function putCardResource({ + url, + href, + etag, + vcard, + username, + password, + allowPrivate = false, + operation, +}) { + const operationName = etag == null ? 'create' : 'update'; + return runCardResourceOperation(operationName, () => { + if (typeof etag === 'string' && etag.trim() === '*') { + throw new CardDavError('CardDAV resource update requires a stored ETag', { + operation: operationName, + }); + } + const resourceUrl = memberHref(href, url); + if (typeof vcard !== 'string') { + throw new CardDavError('CardDAV resource write requires a vCard', { + operation: operationName, + }); + } + return withDavOperation(url, operation, async activeOperation => { + const conditionalHeader = operationName === 'create' + ? { 'If-None-Match': '*' } + : { 'If-Match': etag }; + const result = await davRequest(activeOperation, 'PUT', resourceUrl, { + username, + password, + body: vcard, + headers: { + 'Content-Type': 'text/vcard; charset=utf-8', + ...conditionalHeader, + }, + errorOperation: operationName, + validateRedirect: validateResourceRedirect(url), + allowPrivate, + }); + const finalHref = memberHref(result.requestUrl, url); + return { href: finalHref, etag: result.headers.get('etag') }; + }); + }); +} + +export async function deleteCardResource({ + url, + href, + etag, + username, + password, + allowPrivate = false, + operation, +}) { + return runCardResourceOperation('delete', () => { + if (typeof etag === 'string' && etag.trim() === '*') { + throw new CardDavError('CardDAV resource delete requires a stored ETag', { + operation: 'delete', + }); + } + const resourceUrl = memberHref(href, url); + if (typeof etag !== 'string') { + throw new CardDavError('CardDAV resource delete requires an ETag', { + operation: 'delete', + }); + } + return withDavOperation(url, operation, async activeOperation => { + const result = await davRequest(activeOperation, 'DELETE', resourceUrl, { + username, + password, + headers: { 'If-Match': etag }, + acceptedStatuses: [404], + errorOperation: 'delete', + validateRedirect: validateResourceRedirect(url), + allowPrivate, + }); + return { href: memberHref(result.requestUrl, url) }; + }); + }); +} + +function extractCard(response, href, label, errorContext) { + const failedPropstat = response.propstats.find(({ status }) => status < 200 || status >= 300); + if (failedPropstat) { + throw new CardDavError( + `CardDAV ${errorContext} failed for ${href} (${failedPropstat.status})`, + { status: failedPropstat.status }, + ); + } + const properties = successfulProperties(response); + const etag = optionalProperty(properties, DAV_NS, 'getetag', label); + const addressData = optionalProperty(properties, CARDDAV_NS, 'address-data', label); + if (errorContext === 'snapshot' && (!etag || !addressData)) { + throw new Error(`${label} did not include successful DAV getetag and CardDAV address-data`); + } + const vcard = textOfNode(addressData); + if (errorContext === 'snapshot' && !vcard.trim()) { + throw new Error(`${label} did not include non-empty CardDAV address-data`); + } + if (errorContext === 'multiget' && (!etag || !addressData || !vcard.trim())) { + throw new CardDavError( + `CardDAV multiget response for ${href} did not include non-empty address-data`, + ); + } + return { href, etag: textOfNode(etag), vcard }; } // Pure: extract vCards from an addressbook-query/REPORT multistatus. Exported for // testing. Returns [{ href, etag, vcard }]. export function parseCards(xmlText, baseUrl) { - const xml = parser.parse(xmlText); - const responses = toArray(xml?.multistatus?.response); - if (responses.some(response => /\b507\b/.test(textOf(response.status)))) { - throw new Error('CardDAV server returned a truncated address book response'); - } + const multistatus = parseDavMultistatus(xmlText, 'address book snapshot response'); const cards = []; - for (const response of responses) { - const props = propsOf(response); - const vcard = decodeXmlCharRefs(textOf(props['address-data'])).trim(); - if (!vcard) continue; // collection self-entry or a non-vCard resource - cards.push({ - href: absolute(textOf(response.href) || response.href, baseUrl), - etag: (textOf(props.getetag) || '').replace(/"/g, ''), - vcard, - }); + const collectionUrl = new URL(baseUrl).href; + + for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) { + const label = `CardDAV snapshot response ${index + 1}`; + const response = parseDavResponse(responseNode, label); + const href = memberHref(response.href, collectionUrl, { allowCollection: true }); + const isCollection = href === collectionUrl; + + if (response.status != null) { + if (response.status === 507) { + throw new Error('CardDAV server returned a truncated address book response'); + } + if (response.status < 200 || response.status >= 300) { + throw new CardDavError(`CardDAV snapshot failed for ${href} (${response.status})`, { + status: response.status, + }); + } + if (isCollection) continue; + throw new Error(`${label} did not include requested card properties`); + } + + if (isCollection) { + const failedSelfPropstat = response.propstats.find(({ status }) => ( + status !== 404 && (status < 200 || status >= 300) + )); + if (failedSelfPropstat) { + throw new CardDavError(`CardDAV snapshot failed for ${href} (${failedSelfPropstat.status})`, { + status: failedSelfPropstat.status, + }); + } + continue; + } + + cards.push(extractCard(response, href, label, 'snapshot')); } return cards; } + +// Pure: parse cards and missing resources from an RFC 6352 multiget response. +export function parseMultigetCards(xmlText, baseUrl) { + const multistatus = parseDavMultistatus(xmlText, 'multiget response'); + const collectionUrl = new URL(baseUrl).href; + const results = []; + for (const [index, responseNode] of childrenNamed(multistatus, DAV_NS, 'response').entries()) { + const label = `CardDAV multiget response ${index + 1}`; + const response = parseDavResponse(responseNode, label); + const href = memberHref(response.href, collectionUrl); + if (response.status != null) { + if (response.status === 404) { + results.push({ href, status: response.status }); + continue; + } + throw new CardDavError(`CardDAV multiget failed for ${href} (${response.status})`, { + status: response.status, + }); + } + + results.push(extractCard(response, href, label, 'multiget')); + } + return results; +} diff --git a/backend/src/services/carddavClient.protocol.test.js b/backend/src/services/carddavClient.protocol.test.js new file mode 100644 index 00000000..b0b3bfa6 --- /dev/null +++ b/backend/src/services/carddavClient.protocol.test.js @@ -0,0 +1,1749 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { safeFetch } from './safeFetch.js'; +import { CardDavError } from './carddavTransport.js'; +import { + buildMultigetBody, + buildSyncCollectionBody, + discoverAddressBooks, + fetchAddressBookDelta, + fetchAddressBookCards, + fetchCardResource, + fetchCardsByHref, + fetchSyncPage, + deleteCardResource, + parseMultigetCards, + parseSupportedReports, + parseSyncPage, + putCardResource, +} from './carddavClient.js'; + +vi.mock('./hostValidation.js', () => ({ + validateHost: vi.fn().mockResolvedValue(null), +})); + +vi.mock('./safeFetch.js', () => ({ + safeFetch: vi.fn(), +})); + +const BASE = 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/'; + +function davResponse(xml, status = 207, statusText = 'Multi-Status', url, responseHeaders) { + const encoded = new TextEncoder().encode(xml); + let sent = false; + const bodyRead = vi.fn(async () => { + if (!sent && encoded.byteLength > 0) { + sent = true; + return { done: false, value: encoded }; + } + return { done: true, value: undefined }; + }); + return { + body: { + getReader: () => ({ + cancel: vi.fn().mockResolvedValue(undefined), + read: bodyRead, + releaseLock: vi.fn(), + }), + }, + bodyRead, + headers: new Headers({ + ...responseHeaders, + 'Content-Length': String(encoded.byteLength), + }), + status, + statusText, + ok: status >= 200 && status < 300, + url, + }; +} + +function syncPageXml(hrefs, nextToken, { truncated = false } = {}) { + return syncEventsXml( + hrefs.map((href, index) => ({ href, etag: `sync-${index}` })), + nextToken, + { truncated }, + ); +} + +function multigetXml(hrefs) { + const cards = hrefs.map((href, index) => `${href} + W/"card-${index}" + BEGIN:VCARD +UID:${index} +FN:Contact ${index} +END:VCARD + HTTP/1.1 200 OK`).join(''); + return `${cards}`; +} + +function syncEventsXml(events, nextToken, { truncated = false } = {}) { + const responses = events.map((event, index) => { + if (event.status === 404) { + return `${event.href}HTTP/1.1 404 Not Found`; + } + return `${event.href} + ${event.etag ?? `delta-${index}`} + HTTP/1.1 200 OK`; + }).join(''); + const continuation = truncated + ? './HTTP/1.1 507 Insufficient Storage' + : ''; + return `${responses}${continuation}${nextToken}`; +} + +afterEach(() => { + vi.useRealTimers(); + safeFetch.mockReset(); + vi.clearAllMocks(); +}); + +describe('parseSupportedReports', () => { + it('recognizes sync-collection and addressbook-multiget across namespace prefixes', () => { + const xml = ` + /dav/contacts/ + + + + + HTTP/1.1 200 OK +`; + + expect(parseSupportedReports(xml)).toEqual({ + syncCollection: true, + addressbookMultiget: true, + }); + }); + + it('reports unadvertised capabilities as unsupported', () => { + const xml = `/dav/contacts/ + + HTTP/1.1 200 OK`; + + expect(parseSupportedReports(xml)).toEqual({ + syncCollection: false, + addressbookMultiget: false, + }); + }); +}); + +describe('supported report discovery', () => { + it('requests supported-report-set and returns sync-collection support per book', async () => { + const principal = `/ + /dav/principals/user/ + HTTP/1.1 200 OK`; + const home = `/dav/principals/user/ + /dav/addressbooks/user/ + HTTP/1.1 200 OK`; + const books = ` + /dav/addressbooks/user/ + + HTTP/1.1 200 OK + /dav/addressbooks/user/contacts/ + + Contacts + + + HTTP/1.1 200 OK + `; + safeFetch + .mockResolvedValueOnce(davResponse(principal)) + .mockResolvedValueOnce(davResponse(home)) + .mockResolvedValueOnce(davResponse(books)); + + await expect(discoverAddressBooks({ + serverUrl: 'https://cloud.example.com/', + username: 'user', + password: 'app-password', + })).resolves.toEqual([{ + url: 'https://cloud.example.com/dav/addressbooks/user/contacts/', + displayName: 'Contacts', + supportsSyncCollection: true, + capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' }, + discoveryIndex: 0, + addressData: [], + }]); + + expect(safeFetch).toHaveBeenCalledTimes(3); + expect(safeFetch.mock.calls[2][1].body).toContain(''); + expect(safeFetch.mock.calls[2][1].body).toContain(''); + expect(safeFetch.mock.calls[2][1].body).toContain(''); + }); + + it('returns an empty array for a complete home-set enumeration with no books', async () => { + const principal = `/ + /dav/principals/user/ + HTTP/1.1 200 OK + `; + const home = ` + /dav/principals/user/ + /dav/addressbooks/user/ + HTTP/1.1 200 OK`; + const emptyBooks = ` + /dav/addressbooks/user/ + + HTTP/1.1 200 OK + `; + safeFetch + .mockResolvedValueOnce(davResponse(principal)) + .mockResolvedValueOnce(davResponse(home)) + .mockResolvedValueOnce(davResponse(emptyBooks)); + + await expect(discoverAddressBooks({ + serverUrl: 'https://cloud.example.com/', + username: 'user', + password: 'app-password', + })).resolves.toEqual([]); + expect(safeFetch).toHaveBeenCalledTimes(3); + }); + + it.each([ + { phase: 'principal', expectedCalls: 1 }, + { phase: 'home set', expectedCalls: 2 }, + { phase: 'address book', expectedCalls: 3 }, + ])('rejects a cross-origin $phase href after $expectedCalls same-origin request(s)', async ({ + phase, + expectedCalls, + }) => { + const principalHref = phase === 'principal' + ? 'https://evil.example.test/principal/' + : '/dav/principals/user/'; + const homeHref = phase === 'home set' + ? 'https://evil.example.test/addressbooks/user/' + : '/dav/addressbooks/user/'; + const bookHref = phase === 'address book' + ? 'https://evil.example.test/addressbooks/user/contacts/' + : '/dav/addressbooks/user/contacts/'; + const principal = `/ + ${principalHref} + HTTP/1.1 200 OK + `; + const home = ` + /dav/principals/user/ + ${homeHref} + HTTP/1.1 200 OK`; + const books = ` + /dav/addressbooks/user/ + + HTTP/1.1 200 OK + ${bookHref} + + HTTP/1.1 200 OK + `; + for (const xml of [principal, home, books].slice(0, expectedCalls)) { + safeFetch.mockResolvedValueOnce(davResponse(xml)); + } + + await expect(discoverAddressBooks({ + serverUrl: 'https://cloud.example.com/', + username: 'user', + password: 'app-password', + allowPrivate: true, + })).rejects.toThrow(/origin/i); + expect(safeFetch).toHaveBeenCalledTimes(expectedCalls); + expect(safeFetch.mock.calls.every(([url]) => new URL(url).origin === 'https://cloud.example.com')) + .toBe(true); + }); + + it.each([ + { name: 'empty fragment', href: '/dav/principals/user/#' }, + { name: 'empty userinfo', href: 'https://@cloud.example.com/dav/principals/user/' }, + ])('rejects a principal href with $name before the next request', async ({ href }) => { + const principal = `/ + ${href} + HTTP/1.1 200 OK + `; + safeFetch.mockResolvedValueOnce(davResponse(principal)); + + await expect(discoverAddressBooks({ + serverUrl: 'https://cloud.example.com/', + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' }); + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it.each([ + { + phase: 'principal', + principalHref: String.raw`https:\\@cloud.example.com/dav/principals/user/`, + homeHref: '/dav/addressbooks/user/', + expectedCalls: 1, + }, + { + phase: 'home set', + principalHref: '/dav/principals/user/', + homeHref: String.raw`https:\\@cloud.example.com/dav/addressbooks/user/`, + expectedCalls: 2, + }, + ])('rejects a raw backslash $phase href before the next request', async ({ + principalHref, + homeHref, + expectedCalls, + }) => { + const principal = `/ + ${principalHref} + HTTP/1.1 200 OK + `; + const home = ` + /dav/principals/user/ + ${homeHref} + HTTP/1.1 200 OK`; + for (const xml of [principal, home].slice(0, expectedCalls)) { + safeFetch.mockResolvedValueOnce(davResponse(xml)); + } + + await expect(discoverAddressBooks({ + serverUrl: 'https://cloud.example.com/', + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' }); + expect(safeFetch).toHaveBeenCalledTimes(expectedCalls); + }); +}); + +describe('conditional CardDAV resource operations', () => { + it.each(['"strong"', 'W/"weak"'])( + 'fetches a vCard with opaque ETag %s', + async etag => { + const href = `${BASE}fetched.vcf`; + const vcard = 'BEGIN:VCARD\nVERSION:4.0\nUID:fetched\nEND:VCARD'; + safeFetch.mockResolvedValueOnce(davResponse( + vcard, + 200, + 'OK', + href, + { ETag: etag, 'Content-Type': 'text/vcard; charset=utf-8' }, + )); + + await expect(fetchCardResource({ + url: BASE, + href, + username: 'user', + password: 'app-password', + })).resolves.toEqual({ href, etag, vcard }); + + const [, options] = safeFetch.mock.calls[0]; + const headers = new Headers(options.headers); + expect(options.method).toBe('GET'); + expect(headers.get('accept')).toBe('text/vcard'); + expect(headers.has('content-type')).toBe(false); + }, + ); + + it('creates with If-None-Match and returns the final URL and response ETag', async () => { + const href = `${BASE}created.vcf`; + const vcard = 'BEGIN:VCARD\nVERSION:4.0\nUID:created\nEND:VCARD'; + safeFetch.mockResolvedValueOnce(davResponse( + '', + 201, + 'Created', + href, + { ETag: '"created-1"' }, + )); + + await expect(putCardResource({ + url: BASE, + href: 'created.vcf', + vcard, + username: 'user', + password: 'app-password', + })).resolves.toEqual({ href, etag: '"created-1"' }); + + const [requestUrl, options] = safeFetch.mock.calls[0]; + const headers = new Headers(options.headers); + expect(requestUrl).toBe(href); + expect(options.method).toBe('PUT'); + expect(options.body).toBe(vcard); + expect(headers.get('content-type')).toBe('text/vcard; charset=utf-8'); + expect(headers.get('if-none-match')).toBe('*'); + expect(headers.has('if-match')).toBe(false); + }); + + it('updates with the stored opaque If-Match value', async () => { + const href = `${BASE}updated.vcf`; + const vcard = 'BEGIN:VCARD\nVERSION:3.0\nUID:updated\nEND:VCARD'; + safeFetch.mockResolvedValueOnce(davResponse( + '', + 204, + 'No Content', + href, + { ETag: 'W/"updated-2"' }, + )); + + await expect(putCardResource({ + url: BASE, + href, + etag: 'W/"stored-1"', + vcard, + username: 'user', + password: 'app-password', + })).resolves.toEqual({ href, etag: 'W/"updated-2"' }); + + const headers = new Headers(safeFetch.mock.calls[0][1].headers); + expect(headers.get('if-match')).toBe('W/"stored-1"'); + expect(headers.has('if-none-match')).toBe(false); + }); + + it('deletes with If-Match and treats a missing resource as success', async () => { + const href = `${BASE}already-gone.vcf`; + safeFetch.mockResolvedValueOnce(davResponse('', 404, 'Not Found', href)); + + await expect(deleteCardResource({ + url: BASE, + href, + etag: '"stored-delete"', + username: 'user', + password: 'app-password', + })).resolves.toEqual({ href }); + + const [, options] = safeFetch.mock.calls[0]; + const headers = new Headers(options.headers); + expect(options.method).toBe('DELETE'); + expect(headers.get('if-match')).toBe('"stored-delete"'); + expect(headers.has('content-type')).toBe(false); + }); + + it.each([ + ['update', 'exact', () => putCardResource({ + url: BASE, + href: 'wildcard-update.vcf', + etag: '*', + vcard: 'BEGIN:VCARD\nEND:VCARD', + username: 'user', + password: 'app-password', + })], + ['update', 'OWS-padded', () => putCardResource({ + url: BASE, + href: 'wildcard-update.vcf', + etag: ' * ', + vcard: 'BEGIN:VCARD\nEND:VCARD', + username: 'user', + password: 'app-password', + })], + ['delete', 'exact', () => deleteCardResource({ + url: BASE, + href: 'wildcard-delete.vcf', + etag: '*', + username: 'user', + password: 'app-password', + })], + ['delete', 'OWS-padded', () => deleteCardResource({ + url: BASE, + href: 'wildcard-delete.vcf', + etag: '\t*\t', + username: 'user', + password: 'app-password', + })], + ])('rejects %s wildcard If-Match (%s) before requesting', async (operation, _form, request) => { + safeFetch.mockRejectedValueOnce(new Error('transport should not be reached')); + + await expect(request()).rejects.toMatchObject({ + name: 'CardDavError', + operation, + }); + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it.each([ + ['fetch', 403, () => fetchCardResource({ + url: BASE, href: 'blocked.vcf', username: 'user', password: 'app-password', + })], + ['create', 405, () => putCardResource({ + url: BASE, href: 'blocked.vcf', vcard: 'BEGIN:VCARD\nEND:VCARD', + username: 'user', password: 'app-password', + })], + ['update', 403, () => putCardResource({ + url: BASE, href: 'blocked.vcf', etag: '"stored"', vcard: 'BEGIN:VCARD\nEND:VCARD', + username: 'user', password: 'app-password', + })], + ['delete', 405, () => deleteCardResource({ + url: BASE, href: 'blocked.vcf', etag: '"stored"', + username: 'user', password: 'app-password', + })], + ])('preserves rejected %s operation on HTTP %i', async (operation, status, request) => { + safeFetch.mockResolvedValueOnce(davResponse('', status, 'Rejected', `${BASE}blocked.vcf`)); + + await expect(request()).rejects.toMatchObject({ + name: 'CardDavError', + operation, + status, + }); + }); + + it.each([409, 412, 423, 429, 500, 503])( + 'keeps HTTP %i as a typed conditional update error', + async status => { + safeFetch.mockResolvedValueOnce(davResponse('', status, 'Rejected', `${BASE}blocked.vcf`)); + + await expect(putCardResource({ + url: BASE, + href: 'blocked.vcf', + etag: '"stored"', + vcard: 'BEGIN:VCARD\nEND:VCARD', + username: 'user', + password: 'app-password', + })).rejects.toSatisfy(error => ( + error instanceof CardDavError + && error.operation === 'update' + && error.status === status + )); + }, + ); + + it('rejects a same-origin redirect outside the collection scope', async () => { + const redirected = 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/sibling.vcf'; + safeFetch.mockResolvedValueOnce(davResponse('', 201, 'Created', redirected)); + + await expect(putCardResource({ + url: BASE, + href: 'created.vcf', + vcard: 'BEGIN:VCARD\nEND:VCARD', + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ + code: 'ERR_DAV_HREF_SCOPE', + operation: 'create', + }); + }); + + it('rejects a final cross-origin resource URL with the fetch operation', async () => { + safeFetch.mockResolvedValueOnce(davResponse( + 'BEGIN:VCARD\nEND:VCARD', + 200, + 'OK', + 'https://evil.example.test/stolen.vcf', + { ETag: '"stolen"' }, + )); + + await expect(fetchCardResource({ + url: BASE, + href: 'card.vcf', + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ + operation: 'fetch', + cause: { code: 'ERR_CROSS_ORIGIN_REDIRECT' }, + }); + }); + + it('rejects an out-of-scope resource href before sending credentials', async () => { + await expect(putCardResource({ + url: BASE, + href: 'nested/card.vcf', + vcard: 'BEGIN:VCARD\nEND:VCARD', + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ + code: 'ERR_DAV_HREF_SCOPE', + operation: 'create', + }); + + expect(safeFetch).not.toHaveBeenCalled(); + }); +}); + +describe('initial sync network planner', () => { + it('uses the trusted final collection URL for alias members and identity', async () => { + const observedUrl = 'https://cloud.example.com/books/alias/'; + const canonicalUrl = 'https://cloud.example.com/books/canonical/'; + safeFetch + .mockResolvedValueOnce(davResponse( + syncPageXml(['contact.vcf'], 'opaque-after'), + 207, + 'Multi-Status', + canonicalUrl, + )) + .mockResolvedValueOnce(davResponse( + multigetXml(['contact.vcf']), + 207, + 'Multi-Status', + canonicalUrl, + )); + + const plan = await fetchAddressBookDelta({ + url: observedUrl, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan.collectionIdentity).toEqual({ observedUrl, canonicalUrl }); + expect(plan.upserts[0].href).toBe(`${canonicalUrl}contact.vcf`); + expect(safeFetch).toHaveBeenCalledTimes(2); + }); + + it('follows continuation tokens and multigets 205 hrefs in fixed 100, 100, and 5 batches', async () => { + const hrefs = Array.from({ length: 205 }, (_, index) => `${BASE}${index}.vcf`); + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml(hrefs.slice(0, 100), 'middle-1', { truncated: true }))) + .mockResolvedValueOnce(davResponse(syncPageXml(hrefs.slice(100, 200), 'middle-2', { truncated: true }))) + .mockResolvedValueOnce(davResponse(syncPageXml(hrefs.slice(200), 'final-token'))) + .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(0, 100)))) + .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(100, 200)))) + .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(200)))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(fetchSyncPage).toBeTypeOf('function'); + expect(fetchCardsByHref).toBeTypeOf('function'); + expect(plan).toMatchObject({ + expectedRemoteToken: null, + nextRemoteToken: 'final-token', + capability: 'sync-collection', + replaceAll: true, + removedHrefs: [], + }); + expect(plan.upserts).toHaveLength(205); + expect(plan.upserts[0].etag).toBe('W/"card-0"'); + + const bodies = safeFetch.mock.calls.map(([, options]) => options.body); + expect(bodies.slice(0, 3)).toEqual([ + expect.stringContaining(''), + expect.stringContaining('middle-1'), + expect.stringContaining('middle-2'), + ]); + expect(bodies.slice(3).map(body => body.match(//g)?.length || 0)) + .toEqual([100, 100, 5]); + expect(safeFetch.mock.calls.every(([, options]) => options.depth == null)).toBe(true); + expect(safeFetch.mock.calls.every(([, options]) => options.headers.Depth === '0')).toBe(true); + }); + + it('stops after multiget batch 2 fails', async () => { + const hrefs = Array.from({ length: 205 }, (_, index) => `${BASE}${index}.vcf`); + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml(hrefs, 'final-token'))) + .mockResolvedValueOnce(davResponse(multigetXml(hrefs.slice(0, 100)))) + .mockRejectedValueOnce(new Error('multiget batch 2 failed')); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow('multiget batch 2 failed'); + + expect(safeFetch).toHaveBeenCalledTimes(3); + }); + + it('rejects a final sync page that is not a DAV multistatus', async () => { + safeFetch.mockResolvedValueOnce(davResponse('proxy error')); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow('DAV multistatus'); + + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('rejects a complete final sync page without a usable sync token', async () => { + safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], ' '))); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow('usable sync token'); + + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it.each([405, 501])('does not downgrade a per-resource %i response', async status => { + const statusText = status === 405 ? 'Method Not Allowed' : 'Not Implemented'; + const xml = ` + blocked.vcfHTTP/1.1 ${status} ${statusText} + must-not-advance + `; + safeFetch.mockResolvedValueOnce(davResponse(xml)); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ + name: 'CardDavError', + status, + }); + + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it.each([403, 500])('rejects a sync resource with a %i propstat without accepting its token', async status => { + const xml = ` + failed.vcfstale + HTTP/1.1 ${status} Failed + must-not-advance + `; + safeFetch.mockResolvedValueOnce(davResponse(xml)); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ + name: 'CardDavError', + status, + }); + + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('uses the guarded full query for unsupported books', async () => { + safeFetch.mockResolvedValueOnce(davResponse(multigetXml([`${BASE}one.vcf`]))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-existing-token', + supportsSyncCollection: false, + username: 'user', + password: 'app-password', + }); + + expect(plan).toMatchObject({ + expectedRemoteToken: 'opaque-existing-token', + nextRemoteToken: null, + capability: 'snapshot', + collectionIdentity: { observedUrl: BASE, canonicalUrl: BASE }, + replaceAll: true, + removedHrefs: [], + }); + expect(plan.upserts).toHaveLength(1); + expect(safeFetch).toHaveBeenCalledOnce(); + expect(safeFetch.mock.calls[0][1].body).toContain(' { + safeFetch.mockResolvedValueOnce(davResponse('')); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: false, + username: 'user', + password: 'app-password', + })).resolves.toEqual({ + expectedRemoteToken: 'opaque-before', + nextRemoteToken: null, + capability: 'snapshot', + collectionIdentity: { observedUrl: BASE, canonicalUrl: BASE }, + replaceAll: true, + upserts: [], + removedHrefs: [], + }); + + const body = safeFetch.mock.calls[0][1].body; + expect(body.match(//g)).toHaveLength(1); + expect(body).toContain(''); + expect(body.indexOf('')).toBeGreaterThan(body.indexOf('')); + }); + + it.each([405, 501])('downgrades an advertised sync report returning %i', async status => { + safeFetch + .mockResolvedValueOnce(davResponse('', status, status === 405 ? 'Method Not Allowed' : 'Not Implemented')) + .mockResolvedValueOnce(davResponse(multigetXml([`${BASE}one.vcf`]))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan).toMatchObject({ + nextRemoteToken: null, + capability: 'snapshot', + replaceAll: true, + }); + expect(safeFetch).toHaveBeenCalledTimes(2); + expect(safeFetch.mock.calls[1][1].body).toContain(' { + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml([`${BASE}one.vcf`], 'final-token'))) + .mockResolvedValueOnce(davResponse('', 405, 'Method Not Allowed')) + .mockResolvedValueOnce(davResponse(multigetXml([`${BASE}one.vcf`]))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan.capability).toBe('snapshot'); + expect(safeFetch).toHaveBeenCalledTimes(3); + }); + + it.each([ + { status: 401, statusText: 'Unauthorized', body: '', precondition: null }, + { status: 403, statusText: 'Forbidden', body: '', precondition: null }, + { + status: 403, + statusText: 'Forbidden', + body: '', + precondition: 'valid-sync-token', + }, + ])('does not downgrade strict HTTP failure $status/$precondition', async failure => { + safeFetch.mockResolvedValueOnce(davResponse( + failure.body, + failure.status, + failure.statusText, + )); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ + status: failure.status, + precondition: failure.precondition, + }); + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('does not downgrade a timeout', async () => { + const timeout = new Error('request expired'); + timeout.name = 'TimeoutError'; + safeFetch.mockRejectedValueOnce(timeout); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ + name: 'CardDavError', + status: null, + cause: timeout, + }); + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('does not downgrade a sync-page parse failure', async () => { + safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], ' ', { truncated: true }))); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: null, + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow('truncated without a continuation token'); + expect(safeFetch).toHaveBeenCalledOnce(); + }); +}); + +describe('stored-token delta network planner', () => { + it('returns a no-change delta without issuing a multiget', async () => { + safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], 'opaque-after'))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan).toEqual({ + expectedRemoteToken: 'opaque-before', + nextRemoteToken: 'opaque-after', + capability: 'sync-collection', + collectionIdentity: { observedUrl: BASE, canonicalUrl: BASE }, + replaceAll: false, + upserts: [], + removedHrefs: [], + }); + expect(safeFetch).toHaveBeenCalledOnce(); + expect(safeFetch.mock.calls[0][1].body).toContain('opaque-before'); + }); + + it('allows a final no-change page to return the stored token', async () => { + safeFetch.mockResolvedValueOnce(davResponse(syncPageXml([], 'opaque-same'))); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-same', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).resolves.toMatchObject({ + nextRemoteToken: 'opaque-same', + upserts: [], + removedHrefs: [], + }); + + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('rejects a repeated continuation token after one request', async () => { + safeFetch.mockResolvedValueOnce(davResponse( + syncPageXml([], 'opaque-same', { truncated: true }), + )); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-same', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow(/token.*cycle|repeated.*token/i); + + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('rejects an A to B to A continuation cycle after two requests', async () => { + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml([], 'B', { truncated: true }))) + .mockResolvedValueOnce(davResponse(syncPageXml([], 'A', { truncated: true }))); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'A', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow(/token.*cycle|repeated.*token/i); + + expect(safeFetch).toHaveBeenCalledTimes(2); + }); + + it('round-trips numeric-looking continuation tokens as exact opaque strings', async () => { + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml([], '00123', { truncated: true }))) + .mockResolvedValueOnce(davResponse(syncPageXml([], 'true', { truncated: true }))) + .mockResolvedValueOnce(davResponse(syncPageXml([], '1e3'))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(safeFetch.mock.calls.map(([, options]) => options.body)).toEqual([ + expect.stringContaining('opaque-before'), + expect.stringContaining('00123'), + expect.stringContaining('true'), + ]); + expect(plan.nextRemoteToken).toBe('1e3'); + }); + + it('fetches one changed href for a stored-token delta', async () => { + const href = `${BASE}changed.vcf`; + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml([href], 'opaque-after'))) + .mockResolvedValueOnce(davResponse(multigetXml([href]))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan).toMatchObject({ + expectedRemoteToken: 'opaque-before', + nextRemoteToken: 'opaque-after', + replaceAll: false, + removedHrefs: [], + }); + expect(plan.upserts).toHaveLength(1); + expect(safeFetch).toHaveBeenCalledTimes(2); + expect(safeFetch.mock.calls[1][1].body).toContain(`${href}`); + }); + + it('keeps a response-level 404 as a delta removal without a multiget', async () => { + const href = `${BASE}gone.vcf`; + safeFetch.mockResolvedValueOnce(davResponse(syncEventsXml([ + { href, status: 404 }, + ], 'opaque-after'))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan).toMatchObject({ + replaceAll: false, + upserts: [], + removedHrefs: [href], + }); + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('turns a multiget 404 into a delta removal', async () => { + const href = `${BASE}vanished.vcf`; + const missing = `${href} + HTTP/1.1 404 Not Found`; + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml([href], 'opaque-after'))) + .mockResolvedValueOnce(davResponse(missing)); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan).toMatchObject({ + replaceAll: false, + upserts: [], + removedHrefs: [href], + }); + }); + + it('deduplicates paged delta events using each href latest disposition', async () => { + const changedThenRemoved = `${BASE}removed-later.vcf`; + const removedThenChanged = `${BASE}changed-later.vcf`; + safeFetch + .mockResolvedValueOnce(davResponse(syncEventsXml([ + { href: changedThenRemoved }, + { href: removedThenChanged, status: 404 }, + ], 'middle-token', { truncated: true }))) + .mockResolvedValueOnce(davResponse(syncEventsXml([ + { href: changedThenRemoved, status: 404 }, + { href: removedThenChanged }, + ], 'opaque-after'))) + .mockResolvedValueOnce(davResponse(multigetXml([removedThenChanged]))); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan).toMatchObject({ + expectedRemoteToken: 'opaque-before', + nextRemoteToken: 'opaque-after', + replaceAll: false, + removedHrefs: [changedThenRemoved], + }); + expect(plan.upserts.map(card => card.href)).toEqual([removedThenChanged]); + expect(safeFetch.mock.calls.map(([, options]) => options.body)).toEqual([ + expect.stringContaining('opaque-before'), + expect.stringContaining('middle-token'), + expect.stringContaining(`${removedThenChanged}`), + ]); + }); + + it('rejects duplicate member events within one delta page', async () => { + const href = `${BASE}same-page.vcf`; + safeFetch.mockResolvedValueOnce(davResponse(syncEventsXml([ + { href }, + { href, status: 404 }, + ], 'opaque-after'))); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow(/duplicate|same page/i); + + expect(safeFetch).toHaveBeenCalledOnce(); + }); + + it('does not build a delta after a later sync page fails', async () => { + const href = `${BASE}changed.vcf`; + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml([href], 'middle-token', { truncated: true }))) + .mockRejectedValueOnce(new Error('delta page 2 failed')); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow('delta page 2 failed'); + + expect(safeFetch).toHaveBeenCalledTimes(2); + }); + + it('does not build a delta after a multiget batch fails', async () => { + const href = `${BASE}changed.vcf`; + safeFetch + .mockResolvedValueOnce(davResponse(syncPageXml([href], 'opaque-after'))) + .mockRejectedValueOnce(new Error('delta multiget failed')); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow('delta multiget failed'); + }); + + it('does not build a delta after a sync page parse failure', async () => { + safeFetch.mockResolvedValueOnce(davResponse('not a multistatus')); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow('DAV multistatus'); + }); + + it('accepts exactly 100 sync pages when the last page is final', async () => { + let page = 0; + safeFetch.mockImplementation(() => { + page++; + return Promise.resolve(davResponse(syncPageXml( + [], + `page-${page}`, + { truncated: page < 100 }, + ))); + }); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'stored-token', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan.nextRemoteToken).toBe('page-100'); + expect(safeFetch).toHaveBeenCalledTimes(100); + }); + + it('rejects before issuing a 101st sync page request', async () => { + let page = 0; + safeFetch.mockImplementation(() => { + if (page === 100) { + return Promise.reject(new Error('unexpected 101st sync request')); + } + page++; + return Promise.resolve(davResponse(syncPageXml( + [], + `page-${page}`, + { truncated: true }, + ))); + }); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'stored-token', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow(/100.*page|page.*limit/i); + + expect(safeFetch).toHaveBeenCalledTimes(100); + }); + + it('accepts exactly 50,000 distinct removed members', async () => { + let page = 0; + safeFetch.mockImplementation(() => { + const start = page * 500; + const events = Array.from({ length: 500 }, (_, index) => ({ + href: `${BASE}removed-${start + index}.vcf`, + status: 404, + })); + page++; + return Promise.resolve(davResponse(syncEventsXml( + events, + `page-${page}`, + { truncated: page < 100 }, + ))); + }); + + const plan = await fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + + expect(plan.removedHrefs).toHaveLength(50_000); + expect(safeFetch).toHaveBeenCalledTimes(100); + }); + + it('rejects the 50,001st distinct member before any multiget', async () => { + let page = 0; + safeFetch.mockImplementation(() => { + const start = page * 500; + const pageSize = page === 99 ? 501 : 500; + const hrefs = Array.from( + { length: pageSize }, + (_, index) => `${BASE}changed-${start + index}.vcf`, + ); + page++; + return Promise.resolve(davResponse(syncPageXml( + hrefs, + `page-${page}`, + { truncated: page < 100 }, + ))); + }); + + await expect(fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + })).rejects.toThrow(/50,?000|object.*limit|member.*limit/i); + + expect(safeFetch).toHaveBeenCalledTimes(100); + expect(safeFetch.mock.calls.every(([, options]) => ( + options.body.includes(' { + vi.useFakeTimers(); + const hrefs = Array.from({ length: 1_100 }, (_, index) => `${BASE}slow-${index}.vcf`); + let callIndex = 0; + safeFetch.mockImplementation((_url, options) => { + const index = callIndex++; + const xml = index === 0 + ? syncPageXml(hrefs, 'opaque-after') + : multigetXml(hrefs.slice((index - 1) * 100, index * 100)); + return new Promise((resolve, reject) => { + let timer; + const onAbort = () => { + clearTimeout(timer); + reject(options.signal.reason); + }; + timer = setTimeout(() => { + options.signal.removeEventListener('abort', onAbort); + resolve(davResponse(xml)); + }, 29_000); + options.signal.addEventListener('abort', onAbort, { once: true }); + }); + }); + + const request = fetchAddressBookDelta({ + url: BASE, + syncToken: 'opaque-before', + supportsSyncCollection: true, + username: 'user', + password: 'app-password', + }); + const rejection = expect(request).rejects.toMatchObject({ + name: 'CardDavError', + message: 'CardDAV server did not respond (timed out)', + }); + + await vi.advanceTimersByTimeAsync(300_001); + await rejection; + + expect(safeFetch).toHaveBeenCalledTimes(11); + expect(safeFetch.mock.calls.some(([, options]) => ( + options.body.includes(' { + it('builds an initial sync-collection request with an empty token and sync level 1', () => { + const body = buildSyncCollectionBody(''); + + expect(body).toContain(''); + expect(body).toContain('1'); + expect(body).toContain(''); + }); + + it('XML-escapes opaque sync tokens', () => { + const body = buildSyncCollectionBody('https://opaque.example/token?x=1&y='); + + expect(body).toContain('https://opaque.example/token?x=1&y=<two>'); + expect(body).not.toContain('x=1&y='); + }); + + it('builds an addressbook-multiget request with XML-escaped hrefs', () => { + const body = buildMultigetBody(['/dav/a&b.vcf', '/dav/.vcf']); + + expect(body).toContain(''); + expect(body).toContain(''); + expect(body).toContain('/dav/a&b.vcf'); + expect(body).toContain('/dav/<c>.vcf'); + }); +}); + +describe('parseSyncPage', () => { + it('parses changed resources, response-level removals, and 507 continuation', () => { + const xml = ` + a.vcf"remote-a" + HTTP/1.1 200 OK + gone.vcfHTTP/1.1 404 Not Found + ./HTTP/1.1 507 Insufficient Storage + https://opaque.example/token?x=1&y=2 +`; + + expect(parseSyncPage(xml, BASE)).toEqual({ + changed: [{ href: `${BASE}a.vcf`, etag: '"remote-a"' }], + removed: [{ href: `${BASE}gone.vcf` }], + nextToken: 'https://opaque.example/token?x=1&y=2', + truncated: true, + }); + }); + + it('classifies a response-level 507 against the final request URL', async () => { + const finalUrl = new URL('../canonical/', BASE).href; + const originalHrefXml = ` + ${BASE} + HTTP/1.1 507 Insufficient Storage + must-not-continue + `; + const finalHrefXml = ` + ./ + HTTP/1.1 507 Insufficient Storage + continue-from-final-url + `; + safeFetch + .mockResolvedValueOnce({ ...davResponse(originalHrefXml), url: finalUrl }) + .mockResolvedValueOnce({ ...davResponse(finalHrefXml), url: finalUrl }); + + await expect(fetchSyncPage({ + url: BASE, + syncToken: 'opaque-before', + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' }); + await expect(fetchSyncPage({ + url: BASE, + syncToken: 'opaque-before', + username: 'user', + password: 'app-password', + })).resolves.toMatchObject({ + nextToken: 'continue-from-final-url', + truncated: true, + }); + }); + + it('rejects a complete initial page without a usable sync token', () => { + const xml = ``; + + expect(() => parseSyncPage(xml, BASE)).toThrow('usable sync token'); + }); + + it('rejects a response without a DAV multistatus document', () => { + expect(() => parseSyncPage('proxy error', BASE)) + .toThrow('DAV multistatus'); + }); + + it('rejects a failed propstat even when another propstat succeeds', () => { + const xml = `a&b.vcf + staleHTTP/1.1 404 Not Found + "current"HTTP/1.1 200 OK + next`; + + expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + status: 404, + })); + }); + + it('keeps a changed resource whose strong ETag has an empty opaque value', () => { + const xml = `empty.vcf + ""HTTP/1.1 200 OK + next`; + + expect(parseSyncPage(xml, BASE).changed).toEqual([ + { href: `${BASE}empty.vcf`, etag: '""' }, + ]); + }); + + it('throws for an unexpected response-level status without returning its token', () => { + const xml = ` + blocked.vcfHTTP/1.1 403 Forbidden + must-not-advance + `; + + expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + status: 403, + })); + }); + + it('rejects a 507 propstat on the request URI', () => { + const xml = ` + ./ + HTTP/1.1 507 Insufficient Storage + continue-from-here + `; + + expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + status: 507, + })); + }); + + it('rejects a response-level 507 on a collection member', () => { + const xml = ` + member.vcf + HTTP/1.1 507 Insufficient Storage + must-not-continue + `; + + expect(() => parseSyncPage(xml, BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + status: 507, + })); + }); + + it.each([ + { name: 'missing', href: '' }, + { name: 'sibling', href: '/remote.php/dav/addressbooks/users/brmiller/sibling/' }, + { name: 'nested', href: 'nested/member.vcf' }, + ])('rejects a response-level 507 with a $name href', ({ href }) => { + const hrefElement = href ? `${href}` : ''; + const xml = ` + ${hrefElement}HTTP/1.1 507 Insufficient Storage + must-not-continue + `; + + expect(() => parseSyncPage(xml, BASE)).toThrow(); + }); + + it('rejects a truncated page without a usable continuation token', () => { + const xml = ` + ./HTTP/1.1 507 Insufficient Storage + + `; + + expect(() => parseSyncPage(xml, BASE)) + .toThrow('CardDAV sync response was truncated without a continuation token'); + }); +}); + +describe('parseMultigetCards', () => { + it('returns successful cards and response-level 404s in response order', () => { + const xml = ` + a&b.vcf + "remote-a"BEGIN:VCARD +UID:a +FN:A & B +END:VCARD + HTTP/1.1 200 OK + gone.vcfHTTP/1.1 404 Not Found +`; + + expect(parseMultigetCards(xml, BASE)).toEqual([ + { + href: `${BASE}a&b.vcf`, + etag: '"remote-a"', + vcard: 'BEGIN:VCARD\nUID:a\nFN:A & B\nEND:VCARD', + }, + { href: `${BASE}gone.vcf`, status: 404 }, + ]); + }); + + it('throws a typed error for an unexpected per-resource status', () => { + const xml = ` + blocked.vcfHTTP/1.1 403 Forbidden + `; + + expect(() => parseMultigetCards(xml, BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + message: `CardDAV multiget failed for ${BASE}blocked.vcf (403)`, + status: 403, + precondition: null, + })); + }); + + it('rejects a successful resource without non-empty address-data', () => { + const xml = ` + empty.vcf + "empty" + HTTP/1.1 200 OK + `; + + expect(() => parseMultigetCards(xml, BASE)).toThrow('address-data'); + }); + + it.each([ + { + name: 'wrong-namespace address-data', + properties: `etagBEGIN:VCARD +FN:Poison +END:VCARD`, + }, + { + name: 'missing DAV getetag', + properties: `BEGIN:VCARD +FN:Missing ETag +END:VCARD`, + }, + { + name: 'wrong-namespace getetag', + properties: `etagBEGIN:VCARD +FN:Poison +END:VCARD`, + }, + ])('rejects a successful resource with $name', ({ properties }) => { + const xml = `partial.vcf + ${properties}HTTP/1.1 200 OK + `; + + expect(() => parseMultigetCards(xml, BASE)).toThrow(/getetag|address-data/i); + }); + + it('rejects a propstat-level 404 instead of treating it as a removal', () => { + const xml = ` + gone.vcf + staleBEGIN:VCARD +FN:Stale +END:VCARD + HTTP/1.1 404 Not Found + `; + + expect(() => parseMultigetCards(xml, BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + status: 404, + })); + }); +}); + +describe('fetchCardsByHref completeness', () => { + it('rejects an empty multiget input before requesting it', async () => { + await expect(fetchCardsByHref({ + url: BASE, + hrefs: [], + username: 'user', + password: 'app-password', + })).rejects.toThrow(/non-empty|href/i); + + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it('rejects duplicate normalized multiget inputs before requesting them', async () => { + await expect(fetchCardsByHref({ + url: BASE, + hrefs: ['card.vcf', `${BASE}card.vcf`], + username: 'user', + password: 'app-password', + })).rejects.toThrow(/duplicate|unique/i); + + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it('normalizes a relative requested href once for the body and result identity', async () => { + const href = `${BASE}card.vcf`; + safeFetch.mockResolvedValueOnce(davResponse(multigetXml([href]))); + + await expect(fetchCardsByHref({ + url: BASE, + hrefs: ['card.vcf'], + username: 'user', + password: 'app-password', + })).resolves.toEqual([expect.objectContaining({ href })]); + + expect(safeFetch.mock.calls[0][1].body).toContain(`${href}`); + expect(safeFetch.mock.calls[0][1].body).not.toContain('card.vcf'); + }); + + it('rejects a batch when a requested href has no terminal response', async () => { + safeFetch.mockResolvedValueOnce(davResponse('')); + + await expect(fetchCardsByHref({ + url: BASE, + hrefs: [`${BASE}missing.vcf`], + username: 'user', + password: 'app-password', + })).rejects.toThrow('missing.vcf'); + }); + + it('rejects a batch when a requested href has multiple terminal responses', async () => { + const href = `${BASE}duplicate.vcf`; + safeFetch.mockResolvedValueOnce(davResponse(multigetXml([href, href]))); + + await expect(fetchCardsByHref({ + url: BASE, + hrefs: [href], + username: 'user', + password: 'app-password', + })).rejects.toThrow('duplicate.vcf'); + }); + + it('rejects a batch containing an extra unrequested result', async () => { + const requested = `${BASE}requested.vcf`; + const extra = `${BASE}extra.vcf`; + safeFetch.mockResolvedValueOnce(davResponse(multigetXml([requested, extra]))); + + await expect(fetchCardsByHref({ + url: BASE, + hrefs: [requested], + username: 'user', + password: 'app-password', + })).rejects.toThrow(/extra|unrequested|terminal/i); + }); + + it.each([ + ['sibling', '/remote.php/dav/addressbooks/users/brmiller/sibling.vcf'], + ['parent', '../parent.vcf'], + ['nested', 'nested/card.vcf'], + ['cross-origin', 'https://evil.example.test/card.vcf'], + ['credential-bearing', 'https://user:secret@cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/card.vcf'], + ['fragment', 'card.vcf#fragment'], + ])('rejects an out-of-scope %s multiget response', async (_name, responseHref) => { + const xml = multigetXml([responseHref]); + safeFetch.mockResolvedValueOnce(davResponse(xml)); + + await expect(fetchCardsByHref({ + url: BASE, + hrefs: [`${BASE}card.vcf`], + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' }); + }); + + it('rejects an out-of-scope requested href before requesting it', async () => { + await expect(fetchCardsByHref({ + url: BASE, + hrefs: ['nested/card.vcf'], + username: 'user', + password: 'app-password', + })).rejects.toMatchObject({ code: 'ERR_DAV_HREF_SCOPE' }); + + expect(safeFetch).not.toHaveBeenCalled(); + }); +}); + +describe('CardDavError', () => { + it('preserves status, DAV precondition, cause, and response body parsing', async () => { + const responseBody = ``; + const response = davResponse(responseBody, 403, 'Forbidden'); + safeFetch.mockResolvedValue(response); + + const request = fetchAddressBookCards({ + url: BASE, + username: 'user', + password: 'app-password', + }); + + await expect(request).rejects.toMatchObject({ + name: 'CardDavError', + message: 'CardDAV request failed (403 Forbidden)', + status: 403, + requestStatus: 403, + precondition: 'valid-sync-token', + }); + expect(response.bodyRead).toHaveBeenCalledTimes(2); + + const cause = new Error('socket closed'); + expect(new CardDavError('failed', { status: 500, cause })).toMatchObject({ + name: 'CardDavError', + status: 500, + precondition: null, + cause, + }); + }); + + it('keeps the existing user-facing authentication message', async () => { + const response = davResponse('', 401, 'Unauthorized'); + safeFetch.mockResolvedValue(response); + + const request = fetchAddressBookCards({ + url: BASE, + username: 'user', + password: 'wrong-password', + }); + + await expect(request).rejects.toMatchObject({ + name: 'CardDavError', + message: 'Authentication failed — check the username and app password', + status: 401, + }); + expect(response.bodyRead).toHaveBeenCalledOnce(); + }); + + it('wraps timeouts with the timeout message and original cause', async () => { + const cause = new Error('request expired'); + cause.name = 'TimeoutError'; + safeFetch.mockRejectedValue(cause); + + const request = fetchAddressBookCards({ + url: BASE, + username: 'user', + password: 'app-password', + }); + + await expect(request).rejects.toMatchObject({ + name: 'CardDavError', + message: 'CardDAV server did not respond (timed out)', + status: null, + precondition: null, + cause, + }); + }); + + it('wraps network failures with the reachability message and original cause', async () => { + const cause = new Error('socket closed'); + safeFetch.mockRejectedValue(cause); + + const request = fetchAddressBookCards({ + url: BASE, + username: 'user', + password: 'app-password', + }); + + await expect(request).rejects.toMatchObject({ + name: 'CardDavError', + message: 'Could not reach the CardDAV server: socket closed', + status: null, + precondition: null, + cause, + }); + }); + + it('short-circuits principal discovery on a typed 401 response', async () => { + const response = davResponse('', 401, 'Unauthorized'); + safeFetch.mockResolvedValue(response); + + const request = discoverAddressBooks({ + serverUrl: BASE, + username: 'user', + password: 'wrong-password', + }); + + await expect(request).rejects.toMatchObject({ + name: 'CardDavError', + message: 'Authentication failed — check the username and app password', + status: 401, + }); + expect(safeFetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/src/services/carddavClient.test.js b/backend/src/services/carddavClient.test.js index 1fd18141..e02261fa 100644 --- a/backend/src/services/carddavClient.test.js +++ b/backend/src/services/carddavClient.test.js @@ -1,8 +1,46 @@ import { describe, it, expect } from 'vitest'; -import { parseAddressBooks, parseCards, extractHref } from './carddavClient.js'; +import { + canonicalCollectionUrl, + parseAddressBooks, + parseCards, + extractHref, +} from './carddavClient.js'; import { parseVCard } from '../utils/vcard.js'; const BASE = 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/'; +const BOOK_BASE = 'https://cloud.example.com/dav/c/'; +const CONTACTS_BASE = 'https://cloud.example.com/dav/contacts/'; + +describe('canonicalCollectionUrl', () => { + it.each([ + ['HTTPS://Example.COM:443/a/../%62ooks?view=%7e', 'https://example.com/books/?view=~'], + ['https://example.com/books', 'https://example.com/books/'], + ['https://example.com/a%2fb', 'https://example.com/a%2Fb/'], + ['/books', 'https://example.com/books/'], + ])('canonicalizes %s', (raw, expected) => { + expect(canonicalCollectionUrl(raw, 'https://example.com/')).toBe(expected); + }); + + it.each([ + ['https://example.com:8443/books', 'https://example.com:8443/books/'], + ['https://example.com/Books', 'https://example.com/Books/'], + ['https://example.com/books//', 'https://example.com/books//'], + ['https://example.com/a/b/', 'https://example.com/a/b/'], + ['https://example.com/books?b=2&a=1', 'https://example.com/books/?b=2&a=1'], + ['https://example.com/books?a=1&b=2', 'https://example.com/books/?a=1&b=2'], + ])('preserves distinct identity for %s', (value, expected) => { + expect(canonicalCollectionUrl(value)).toBe(expected); + }); + + it.each([ + 'not a url', + 'ftp://example.com/books', + 'https://user:pass@example.com/books', + 'https://example.com/books#fragment', + ])('rejects invalid collection URL %s', value => { + expect(() => canonicalCollectionUrl(value)).toThrow(); + }); +}); describe('extractHref (discovery)', () => { it('pulls current-user-principal href, resolving relative to origin', () => { @@ -31,9 +69,117 @@ describe('extractHref (discovery)', () => { HTTP/1.1 404 Not Found`; expect(extractHref(xml, 'current-user-principal', BASE)).toBeNull(); }); + + it('does not accept a same-local-name property from a foreign namespace', () => { + const xml = ` + /remote.php/dav/ + /poison/ + HTTP/1.1 200 OK + `; + + expect(extractHref(xml, 'current-user-principal', BASE)).toBeNull(); + }); }); describe('parseAddressBooks', () => { + it('preserves discovery order, direct privileges, and ordered address-data attributes', () => { + const xml = ` + ${BASE} + + HTTP/1.1 200 OK + personal/ + + Personal + + + + + + + + + + + + HTTP/1.1 200 OK + shared/ + + Shared + HTTP/1.1 200 OK + `; + + expect(parseAddressBooks(xml, BASE)).toEqual([ + { + url: `${BASE}personal/`, + displayName: 'Personal', + supportsSyncCollection: false, + capabilities: { create: 'allowed', update: 'allowed', delete: 'denied' }, + discoveryIndex: 0, + addressData: [ + { contentType: 'text/vcard', version: '4.0' }, + { contentType: 'text/x-vcard', version: '3.0' }, + { contentType: 'text/vcard', version: '3.0' }, + { contentType: 'text/x-vcard', version: '3.0' }, + { contentType: 'text/vcard', version: '4.0' }, + ], + }, + { + url: `${BASE}shared/`, + displayName: 'Shared', + supportsSyncCollection: false, + capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' }, + discoveryIndex: 1, + addressData: [], + }, + ]); + }); + + it('expands DAV write and all aggregate privileges', () => { + const bookResponse = (href, privilege) => `${href} + + + + HTTP/1.1 200 OK`; + const xml = ` + ${BASE} + HTTP/1.1 200 OK + + ${bookResponse('write/', 'write')}${bookResponse('all/', 'all')} + `; + + expect(parseAddressBooks(xml, BASE).map(book => book.capabilities)).toEqual([ + { create: 'allowed', update: 'allowed', delete: 'allowed' }, + { create: 'allowed', update: 'allowed', delete: 'allowed' }, + ]); + }); + + it('collapses equivalent collection spellings while preserving first response order', () => { + const xml = ` + ${BASE}HTTP/1.1 200 OK + /remote.php/dav/addressbooks/users/brmiller/%63ontactsContactsHTTP/1.1 200 OK + https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/ContactsHTTP/1.1 200 OK + `; + + expect(parseAddressBooks(xml, BASE)).toEqual([{ + url: 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/', + displayName: 'Contacts', + supportsSyncCollection: false, + capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' }, + discoveryIndex: 0, + addressData: [], + }]); + }); + + it('rejects equivalent duplicates with conflicting metadata', () => { + const xml = ` + ${BASE}HTTP/1.1 200 OK + /remote.php/dav/addressbooks/users/brmiller/contacts/ContactsHTTP/1.1 200 OK + /remote.php/dav/addressbooks/users/brmiller/%63ontactsOtherHTTP/1.1 200 OK + `; + + expect(() => parseAddressBooks(xml, BASE)).toThrow(/canonical|conflict|metadata/i); + }); + it('returns only addressbook collections, resolving relative hrefs', () => { const xml = ` @@ -47,33 +193,155 @@ describe('parseAddressBooks', () => { Contacts 42 + + + HTTP/1.1 200 OK `; const books = parseAddressBooks(xml, BASE); - expect(books).toHaveLength(1); // the plain collection is excluded - expect(books[0].displayName).toBe('Contacts'); - expect(books[0].url).toBe('https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/'); + expect(books).toEqual([{ + url: 'https://cloud.example.com/remote.php/dav/addressbooks/users/brmiller/contacts/', + displayName: 'Contacts', + supportsSyncCollection: true, + capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' }, + discoveryIndex: 0, + addressData: [], + }]); }); it('is namespace-prefix agnostic (uppercase D:/C:)', () => { + const xml = ` + /dav/ + HTTP/1.1 200 OK + /dav/work/ + Work + HTTP/1.1 200 OK + +`; + const books = parseAddressBooks(xml, 'https://cloud.example.com/dav/'); + expect(books).toEqual([{ + url: 'https://cloud.example.com/dav/work/', + displayName: 'Work', + supportsSyncCollection: false, + capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' }, + discoveryIndex: 0, + addressData: [], + }]); + }); + + it('returns an authoritative empty list when only the home collection self response exists', () => { + const xml = `${BASE} + + HTTP/1.1 200 OK + `; + + expect(parseAddressBooks(xml, BASE)).toEqual([]); + }); + + it('accepts literal at signs and percent-encoded delimiters in collection path segments', () => { + const hrefs = ['book@team/', 'book%40team/', 'book%23team/']; + const responses = hrefs.map(href => `${href} + + HTTP/1.1 200 OK + `).join(''); const xml = ` - /dav/work/ - Work - HTTP/1.1 200 OK - -`; - const books = parseAddressBooks(xml, BASE); - expect(books).toHaveLength(1); - expect(books[0].displayName).toBe('Work'); + ${BASE} + HTTP/1.1 200 OK + ${responses}`; + + expect(parseAddressBooks(xml, BASE).map(book => book.url)).toEqual( + hrefs.map(href => new URL(href, BASE).href), + ); + }); + + it('ignores a complete non-addressbook child after examining its resourcetype', () => { + const xml = `${BASE} + + HTTP/1.1 200 OK + notes.txt + HTTP/1.1 200 OK + `; + + expect(parseAddressBooks(xml, BASE)).toEqual([]); + }); + + it('rejects an empty multistatus without the required home collection self response', () => { + expect(() => parseAddressBooks('', BASE)) + .toThrow(/self response|home collection/i); + }); + + it.each([ + { + name: 'address book missing its href', + response: ` + + HTTP/1.1 200 OK`, + }, + { + name: 'foreign addressbook lookalike', + response: `poison/ + + HTTP/1.1 200 OK`, + }, + { + name: 'malformed propstat', + response: `broken/ + `, + }, + { + name: 'member outside the home collection', + response: `/remote.php/dav/addressbooks/users/brmiller-evil/contacts/ + + HTTP/1.1 200 OK`, + }, + ])('rejects incomplete discovery: $name', ({ response }) => { + const xml = `${BASE} + + HTTP/1.1 200 OK${response}`; + + expect(() => parseAddressBooks(xml, BASE)).toThrow(); + }); + + it('rejects the 1,001st discovery response', () => { + const responses = Array.from({ length: 1_000 }, (_, index) => ` + book-${index}/ + + HTTP/1.1 200 OK`).join(''); + const xml = ` + ${BASE} + HTTP/1.1 200 OK + ${responses}`; + + expect(() => parseAddressBooks(xml, BASE)).toThrow(/1,000|discovery response/i); }); }); describe('parseCards', () => { + it('accepts an exact empty DAV multistatus as a complete snapshot', () => { + expect(parseCards('', BOOK_BASE)).toEqual([]); + }); + + it('ignores a canonical collection self response expressed as ./', () => { + const xml = `./ + collection + HTTP/1.1 200 OK`; + + expect(parseCards(xml, BOOK_BASE)).toEqual([]); + }); + + it.each([ + 'proxy error', + '', + ])('rejects a non-DAV snapshot document', xml => { + expect(() => parseCards(xml, BOOK_BASE)).toThrow(/DAV multistatus/); + }); + it('rejects a truncated address-book response', () => { const xml = ` /dav/c/uid1.vcf - BEGIN:VCARD + etagBEGIN:VCARD UID:uid1 FN:Jane Doe END:VCARDHTTP/1.1 200 OK @@ -81,11 +349,11 @@ END:VCARDHTTP/1.1 200 OK/dav/c/HTTP/1.1 507 Insufficient Storage `; - expect(() => parseCards(xml, BASE)) + expect(() => parseCards(xml, BOOK_BASE)) .toThrow('CardDAV server returned a truncated address book response'); }); - it('extracts vCards + etags and skips entries without address-data', () => { + it('extracts vCards and ignores a well-formed canonical collection self response', () => { const xml = ` /dav/contacts/ @@ -94,7 +362,7 @@ END:VCARDHTTP/1.1 200 OK /dav/contacts/uid1.vcf - "abc123" + W/"abc123" BEGIN:VCARD VERSION:3.0 UID:uid1 @@ -104,9 +372,9 @@ END:VCARD HTTP/1.1 200 OK `; - const cards = parseCards(xml, BASE); + const cards = parseCards(xml, CONTACTS_BASE); expect(cards).toHaveLength(1); // the collection self-entry (no address-data) is skipped - expect(cards[0].etag).toBe('abc123'); // quotes stripped + expect(cards[0].etag).toBe('W/"abc123"'); expect(cards[0].url ?? cards[0].href).toContain('uid1.vcf'); expect(cards[0].vcard.startsWith('BEGIN:VCARD')).toBe(true); @@ -117,6 +385,146 @@ END:VCARD expect(parsed.emails[0].value).toBe('john@example.com'); }); + it('rejects a failed member propstat instead of returning a partial snapshot', () => { + const xml = ` + /dav/c/stale.vcf + staleBEGIN:VCARD +UID:stale +FN:Stale Contact +END:VCARD + HTTP/1.1 404 Not Found + /dav/c/current.vcf + currentBEGIN:VCARD +UID:current +FN:Current Contact +END:VCARD + HTTP/1.1 200 OK +`; + + expect(() => parseCards(xml, BOOK_BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + status: 404, + })); + }); + + it.each([ + { + name: 'missing href', + response: `etag + BEGIN:VCARD\nFN:Missing href\nEND:VCARD + HTTP/1.1 200 OK`, + }, + { + name: 'propstat missing status', + response: `broken.vcf + etagBEGIN:VCARD\nFN:Broken\nEND:VCARD + `, + }, + ])('rejects a malformed snapshot response: $name', ({ response }) => { + const xml = ` + ${response}`; + + expect(() => parseCards(xml, BOOK_BASE)).toThrow(); + }); + + it('rejects a failed response-level member status', () => { + const xml = `gone.vcf + HTTP/1.1 404 Not Found`; + + expect(() => parseCards(xml, BOOK_BASE)).toThrow(expect.objectContaining({ + name: 'CardDavError', + status: 404, + })); + }); + + it.each([ + { + name: 'missing CardDAV address-data', + properties: 'etag', + }, + { + name: 'foreign address-data lookalike', + properties: `etagBEGIN:VCARD +FN:Poison +END:VCARD`, + }, + { + name: 'missing DAV getetag', + properties: `BEGIN:VCARD +FN:No ETag +END:VCARD`, + }, + ])('rejects a partial snapshot member: $name', ({ properties }) => { + const xml = `partial.vcf + ${properties}HTTP/1.1 200 OK + `; + + expect(() => parseCards(xml, BOOK_BASE)).toThrow(); + }); + + it.each([ + 'https://evil.example.test/dav/c/card.vcf', + 'https://user:secret@cloud.example.com/dav/c/card.vcf', + 'card.vcf#fragment', + '/dav/c-evil/card.vcf', + '/dav/c/../escape.vcf', + 'nested/card.vcf', + 'nested%2Fcard.vcf', + 'nested%5Ccard.vcf', + ])('rejects a snapshot member outside direct collection scope: %s', href => { + const xml = ` + ${href}etag + BEGIN:VCARD\nFN:Out of scope\nEND:VCARD + HTTP/1.1 200 OK + `; + + expect(() => parseCards(xml, BOOK_BASE)).toThrow(/href|scope|collection|origin/i); + }); + + it.each([ + { name: 'empty fragment', href: 'card.vcf#' }, + { name: 'empty userinfo', href: 'https://@cloud.example.com/dav/c/card.vcf' }, + ])('rejects a snapshot href with $name', ({ href }) => { + const xml = ` + ${href}etag + BEGIN:VCARD\nFN:Out of scope\nEND:VCARD + HTTP/1.1 200 OK + `; + + expect(() => parseCards(xml, BOOK_BASE)).toThrow(/credentials|fragment/i); + }); + + it('accepts literal at signs and percent-encoded delimiters in member path segments', () => { + const hrefs = ['person@company.vcf', 'person%40company.vcf', 'card%23.vcf']; + const responses = hrefs.map(href => `${href} + etag + BEGIN:VCARD\nFN:Valid member\nEND:VCARD + HTTP/1.1 200 OK`).join(''); + const xml = ` + ${responses}`; + + expect(parseCards(xml, BOOK_BASE).map(card => card.href)).toEqual( + hrefs.map(href => new URL(href, BOOK_BASE).href), + ); + }); + + it('preserves numeric-looking ETags and exact vCard whitespace after entity decoding', () => { + const xml = ` + opaque.vcf?rev=001 + 0007 BEGIN:VCARD +FN:A & B +END:VCARD + HTTP/1.1 200 OK + `; + + expect(parseCards(xml, BOOK_BASE)).toEqual([{ + href: `${BOOK_BASE}opaque.vcf?rev=001`, + etag: '0007', + vcard: ' BEGIN:VCARD\nFN:A & B\nEND:VCARD ', + }]); + }); + it('maps a Nextcloud vCard with grouped (item1.EMAIL) properties', () => { const xml = ` /dav/c/g.vcf @@ -132,7 +540,7 @@ END:VCARD HTTP/1.1 200 OK `; - const parsed = parseVCard(parseCards(xml, BASE)[0].vcard); + const parsed = parseVCard(parseCards(xml, BOOK_BASE)[0].vcard); expect(parsed.uid).toBe('grp-1'); expect(parsed.emails[0].value).toBe('jane@example.com'); // grouped email is not lost expect(parsed.phones[0].value).toBe('+15551234567'); @@ -148,32 +556,33 @@ END:VCARD HTTP/1.1 200 OK `; - const cards = parseCards(xml, BASE); + const cards = parseCards(xml, BOOK_BASE); expect(cards[0].vcard).toContain('Tom & Jerry'); }); - it('decodes numeric character references (Nextcloud encodes vCard CRLF as )', () => { - // SabreDAV/Nextcloud emits each line's trailing CR as the numeric reference - // inside ; fast-xml-parser leaves those literal, so without - // decoding every parsed field would end in " " and an empty property would - // render as just " " (issue #242). Decimal/hex refs must both be handled. + it('decodes numeric XML character references inside address-data', () => { const xml = ` - /dav/c/n.vcf - n + /dav/c/numeric.vcf + e BEGIN:VCARD -VERSION:3.0 -FN:Andrée -NOTE: -END:VCARD - +FN:Jane Doe +PHOTO;ENCODING=b;TYPE=PNG:AQID +END:VCARD HTTP/1.1 200 OK `; - const card = parseCards(xml, BASE)[0]; - expect(card.vcard).not.toContain(' '); // CR references decoded away - const parsed = parseVCard(card.vcard); - expect(parsed.displayName).toBe('Andrée'); // decimal é decoded, no trailing junk - expect(parsed.notes).toBeNull(); // empty NOTE no longer " " + + const card = parseCards(xml, BOOK_BASE)[0]; + expect(card.vcard).toBe([ + 'BEGIN:VCARD', + 'FN:Jane Doe', + 'PHOTO;ENCODING=b;TYPE=PNG:AQID', + 'END:VCARD', + ].join('\r\n')); + expect(parseVCard(card.vcard)).toMatchObject({ + displayName: 'Jane Doe', + photoData: 'data:image/png;base64,AQID', + }); }); it('parses a large book whose response exceeds 1000 XML entity references', () => { @@ -193,7 +602,7 @@ END:VCARD HTTP/1.1 200 OK `).join(''); const xml = `${responses}`; - const cards = parseCards(xml, BASE); + const cards = parseCards(xml, BOOK_BASE); expect(cards).toHaveLength(N); expect(cards[0].vcard).toContain('Tom <0> Ltd'); // entities still decoded }); diff --git a/backend/src/services/carddavConflictService.js b/backend/src/services/carddavConflictService.js new file mode 100644 index 00000000..a2957c71 --- /dev/null +++ b/backend/src/services/carddavConflictService.js @@ -0,0 +1,387 @@ +import { createHash } from 'node:crypto'; + +import { + contactFromVCardDocument, + localContactHash, + localVCardEtag, + parseVCardDocument, + primaryEmail, + semanticVCardHash, +} from '../utils/vcardProperties.js'; +import { + deleteCardResource, + fetchCardResource, + putCardResource, +} from './carddavClient.js'; +import { query, withTransaction } from './db.js'; +import { + confirmedRemotePayload, + insertContact, + updateStoredContact, +} from './carddavContactService.js'; +import { + refreshUnresolvedConflict, + resolveCarddavConflict, + rotateBookToken, + typedError, +} from './carddavMappingState.js'; +import { resolveCarddavCredentials } from './carddavTransport.js'; + +const RESOLUTIONS = new Set(['keep-mailflow', 'keep-carddav']); + +function isoDate(value) { + return value?.toISOString?.() ?? value ?? null; +} + +function publicSnapshot(vcard, tombstone) { + if (tombstone) return { tombstone: true, hasPhoto: false, contact: null }; + const document = parseVCardDocument(vcard); + const contact = contactFromVCardDocument(document); + delete contact.photoData; + return { + tombstone: false, + hasPhoto: document.properties.some(property => property.name === 'PHOTO'), + contact, + }; +} + +function publicConflict(row) { + return { + id: row.id, + href: row.href, + status: row.status, + resolution: row.resolution ?? null, + createdAt: isoDate(row.created_at), + updatedAt: isoDate(row.updated_at), + resolvedAt: isoDate(row.resolved_at), + local: publicSnapshot(row.local_vcard, row.local_tombstone), + remote: publicSnapshot(row.remote_vcard, row.remote_tombstone), + }; +} + +const PUBLIC_CONFLICT_COLUMNS = ` + conflict.id, conflict.href, conflict.status, conflict.resolution, + conflict.local_vcard, conflict.remote_vcard, + conflict.local_tombstone, conflict.remote_tombstone, + conflict.created_at, conflict.updated_at, conflict.resolved_at`; + +export async function listConflicts(userId) { + const { rows } = await query( + `SELECT ${PUBLIC_CONFLICT_COLUMNS} + FROM carddav_conflicts conflict + JOIN carddav_remote_objects mapping + ON mapping.address_book_id = conflict.address_book_id + AND mapping.href = conflict.href + JOIN address_books remote_book ON remote_book.id = mapping.address_book_id + JOIN user_integrations integration + ON integration.user_id = remote_book.user_id + AND integration.provider = 'carddav' + WHERE integration.user_id = $1 AND conflict.user_id = $1 + AND conflict.status = 'unresolved' + ORDER BY conflict.updated_at DESC, conflict.id`, + [userId], + ); + return rows.map(publicConflict); +} + +export async function getConflict(userId, id) { + const { rows: [row] } = await query( + `SELECT ${PUBLIC_CONFLICT_COLUMNS} + FROM carddav_conflicts conflict + LEFT JOIN carddav_remote_objects mapping + ON mapping.address_book_id = conflict.address_book_id + AND mapping.href = conflict.href + JOIN address_books remote_book ON remote_book.id = conflict.address_book_id + JOIN user_integrations integration + ON integration.user_id = remote_book.user_id + AND integration.provider = 'carddav' + WHERE integration.user_id = $1 AND conflict.user_id = $1 + AND conflict.id = $2 + AND (conflict.status = 'resolved' OR mapping.address_book_id IS NOT NULL)`, + [userId, id], + ); + return row ? publicConflict(row) : null; +} + +function resolutionStateSql(lock = false) { + const mappingJoin = lock ? 'JOIN' : 'LEFT JOIN'; + return `SELECT conflict.*, mapping.mapping_revision::text, mapping.mapping_status, + contact.id AS contact_id, contact.uid AS contact_uid, + contact.etag AS contact_etag, + contact.address_book_id AS local_address_book_id, + remote_book.external_url AS remote_book_url, + integration.config, + integration.config->>'connectionGeneration' AS connection_generation + FROM carddav_conflicts conflict + ${mappingJoin} carddav_remote_objects mapping + ON mapping.address_book_id = conflict.address_book_id + AND mapping.href = conflict.href + JOIN address_books remote_book + ON remote_book.id = conflict.address_book_id + LEFT JOIN contacts contact + ON contact.id = mapping.local_contact_id + AND contact.user_id = remote_book.user_id + JOIN user_integrations integration + ON integration.user_id = remote_book.user_id + AND integration.provider = 'carddav' + WHERE integration.user_id = $1 AND conflict.user_id = $1 + AND conflict.id = $2 + AND (conflict.status = 'resolved' OR mapping.address_book_id IS NOT NULL) + ${lock ? 'FOR UPDATE OF conflict, mapping, integration' : ''}`; +} + +async function readResolutionState(executor, userId, id, lock = false) { + const { rows: [state] } = await executor.query( + resolutionStateSql(lock), + [userId, id], + ); + return state || null; +} + +function staleConflict(id) { + return typedError( + 'CardDAV conflict is stale or already resolved', + 'ERR_CARDDAV_CONFLICT_STALE', + { conflictId: id }, + ); +} + +function assertUnresolved(state, id) { + if (!state) { + throw typedError('CardDAV conflict not found', 'ERR_CARDDAV_CONFLICT_NOT_FOUND'); + } + if (state.status !== 'unresolved') throw staleConflict(id); +} + +function sameResolutionFence(state, preflight) { + return state + && state.status === 'unresolved' + && state.contact_id === preflight.contact_id + && state.contact_etag === preflight.contact_etag + && String(state.mapping_revision) === String(preflight.mapping_revision) + && state.connection_generation === preflight.connection_generation; +} + +async function credentials(state) { + const resolved = await resolveCarddavCredentials(state.config); + if (!state.remote_book_url || !resolved.username || !resolved.password) { + throw typedError( + 'Stored CardDAV credentials could not be read', + 'ERR_CARDDAV_CREDENTIALS', + ); + } + return resolved; +} + +async function fetchLatestRemote(state, creds) { + try { + const remote = await fetchCardResource({ + url: state.remote_book_url, + href: state.href, + ...creds, + }); + return { ...remote, tombstone: false }; + } catch (error) { + if (error?.status === 404) { + return { href: state.href, etag: null, vcard: null, tombstone: true }; + } + throw error; + } +} + +function canonicalPayload(state, remote) { + const localUid = state.contact_uid + ?? createHash('sha256').update(state.href).digest('hex'); + const payload = confirmedRemotePayload(localUid, remote.vcard); + const remoteContact = contactFromVCardDocument(payload.document); + const localVCard = remoteContact.uid === state.contact_uid ? remote.vcard : payload.vcard; + const localEtag = localVCard === payload.vcard ? payload.etag : localVCardEtag(localVCard); + return { + ...payload, + remoteContact, + localContact: { ...payload, uid: localUid }, + localVCard, + localEtag, + vcard: localVCard, + etag: localEtag, + }; +} + +async function updateLocalContact(client, state, payload, userId) { + const row = await updateStoredContact( + client, + userId, + state.contact_id, + payload, + null, + { returning: 'id', onMissing: () => staleConflict(state.id) }, + ); + return row.id; +} + +async function insertLocalContact(client, state, payload, userId) { + const row = await insertContact( + client, + userId, + state.address_book_id, + payload, + { returning: 'id' }, + ); + if (!row) throw staleConflict(state.id); + return row.id; +} + +async function bumpLocalBook(client, state, userId, addressBookId = null) { + const rowCount = await rotateBookToken( + client, + userId, + addressBookId ?? state.local_address_book_id, + ); + if (rowCount !== 1) throw staleConflict(state.id); +} + +function assertMappingApplied(result, conflictId) { + if (!result.ok) throw staleConflict(conflictId); +} + +async function commitResolution(userId, preflight, resolution, remote) { + const payload = remote.tombstone ? null : canonicalPayload(preflight, remote); + return withTransaction(async client => { + await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE'); + const state = await readResolutionState(client, userId, preflight.id, true); + if (!sameResolutionFence(state, preflight)) throw staleConflict(preflight.id); + + if (remote.tombstone) { + const result = await resolveCarddavConflict(client, { + addressBookId: state.address_book_id, + href: state.href, + expectedMappingRevision: state.mapping_revision, + conflictId: state.id, + userId, + resolution, + remoteTombstone: true, + }); + assertMappingApplied(result, state.id); + if (state.contact_id) { + const deleted = await client.query( + 'DELETE FROM contacts WHERE id = $1 AND user_id = $2', + [state.contact_id, userId], + ); + if (deleted.rowCount !== 1) throw staleConflict(state.id); + await bumpLocalBook(client, state, userId); + } + return publicConflict({ ...state, ...result.conflict }); + } + + const localContactId = state.contact_id + ? await updateLocalContact(client, state, payload, userId) + : await insertLocalContact(client, state, payload, userId); + const result = await resolveCarddavConflict(client, { + addressBookId: state.address_book_id, + href: state.href, + expectedMappingRevision: state.mapping_revision, + conflictId: state.id, + userId, + resolution, + remoteTombstone: false, + remoteEtag: remote.etag, + vcard: remote.vcard, + primaryEmail: primaryEmail(payload.remoteContact), + localContactId, + vcardVersion: payload.document.version, + remoteSemanticHash: semanticVCardHash(payload.document), + localContactHash: localContactHash(payload.localContact), + }); + assertMappingApplied(result, state.id); + await bumpLocalBook( + client, + state, + userId, + state.local_address_book_id ?? state.address_book_id, + ); + return publicConflict({ ...state, ...result.conflict }); + }); +} + +async function refreshConflictAfter412(userId, preflight, remote) { + const document = remote.tombstone ? null : parseVCardDocument(remote.vcard); + await withTransaction(async client => { + await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE'); + const state = await readResolutionState(client, userId, preflight.id, true); + if (!sameResolutionFence(state, preflight)) throw staleConflict(preflight.id); + const result = await refreshUnresolvedConflict(client, { + addressBookId: state.address_book_id, + href: state.href, + expectedMappingRevision: state.mapping_revision, + userId, + baseLocalHash: state.base_local_hash, + remoteEtag: remote.etag, + primaryEmail: document ? primaryEmail(contactFromVCardDocument(document)) : null, + vcardVersion: document?.version ?? null, + remoteSemanticHash: document ? semanticVCardHash(document) : null, + preserveLocalSnapshot: true, + localVCard: undefined, + remoteVCard: remote.vcard, + localTombstone: undefined, + remoteTombstone: remote.tombstone, + }); + assertMappingApplied(result, state.id); + if (result.conflict?.id !== state.id) throw staleConflict(state.id); + }); +} + +export async function resolveConflict(userId, id, resolution) { + if (!RESOLUTIONS.has(resolution)) { + throw typedError( + 'Invalid CardDAV conflict resolution', + 'ERR_CARDDAV_CONFLICT_RESOLUTION', + ); + } + + const preflight = await readResolutionState({ query }, userId, id); + assertUnresolved(preflight, id); + const creds = await credentials(preflight); + + if (resolution === 'keep-carddav') { + const remote = await fetchLatestRemote(preflight, creds); + return commitResolution(userId, preflight, resolution, remote); + } + + const latest = await fetchLatestRemote(preflight, creds); + try { + if (preflight.local_tombstone) { + if (!latest.tombstone) { + await deleteCardResource({ + url: preflight.remote_book_url, + href: preflight.href, + etag: latest.etag, + ...creds, + }); + } + } else { + await putCardResource({ + url: preflight.remote_book_url, + href: preflight.href, + etag: latest.etag, + vcard: preflight.local_vcard, + ...creds, + }); + } + } catch (error) { + if (error?.status !== 412) throw error; + const refreshed = await fetchLatestRemote(preflight, creds); + await refreshConflictAfter412(userId, preflight, refreshed); + throw staleConflict(id); + } + const canonical = await fetchLatestRemote(preflight, creds); + return commitResolution(userId, preflight, resolution, canonical); +} + +export async function deleteResolvedConflictsBefore(client, cutoff) { + const result = await client.query( + `DELETE FROM carddav_conflicts + WHERE status = 'resolved' AND resolved_at < $1`, + [cutoff], + ); + return result.rowCount; +} diff --git a/backend/src/services/carddavConflictService.test.js b/backend/src/services/carddavConflictService.test.js new file mode 100644 index 00000000..d196450a --- /dev/null +++ b/backend/src/services/carddavConflictService.test.js @@ -0,0 +1,717 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + decrypt: vi.fn(value => value), + deleteCardResource: vi.fn(), + fetchCardResource: vi.fn(), + getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })), + putCardResource: vi.fn(), + query: vi.fn(), + withTransaction: vi.fn(), +})); + +vi.mock('./db.js', () => ({ + query: mocks.query, + withTransaction: mocks.withTransaction, +})); +vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt })); +vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy })); +vi.mock('./carddavClient.js', () => ({ + deleteCardResource: mocks.deleteCardResource, + fetchCardResource: mocks.fetchCardResource, + putCardResource: mocks.putCardResource, +})); + +import * as conflictService from './carddavConflictService.js'; +import { refreshUnresolvedConflict } from './carddavMappingState.js'; + +const recordCarddavConflict = async (client, change) => { + const result = await refreshUnresolvedConflict(client, change); + return result.ok ? result.conflict : result; +}; + +const BOOK_ID = '00000000-0000-4000-8000-000000000002'; +const CONFLICT_ID = '00000000-0000-4000-8000-000000000009'; +const USER_ID = '00000000-0000-4000-8000-000000000001'; +const OTHER_USER_ID = '00000000-0000-4000-8000-000000000008'; +const HREF = 'https://dav.example.test/books/default/contact.vcf'; +const LOCAL_BOOK_ID = '00000000-0000-4000-8000-000000000003'; + +const photoVCard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:contact-1', + 'FN:Ada Lovelace', + 'N:Lovelace;Ada;;;', + 'EMAIL;TYPE=WORK:ada@example.test', + 'PHOTO;ENCODING=b;TYPE=PNG:AQID', + 'END:VCARD', + '', +].join('\r\n'); + +const remoteVCard = photoVCard + .replace('FN:Ada Lovelace', 'FN:Ada Byron') + .replace('N:Lovelace;Ada', 'N:Byron;Ada') + .replace('PHOTO;ENCODING=b;TYPE=PNG:AQID\r\n', ''); + +const uriPhotoVCard = remoteVCard.replace( + 'END:VCARD\r\n', + 'PHOTO;VALUE=URI:https://images.example.test/private.jpg\r\nEND:VCARD\r\n', +); + +function conflictRow(overrides = {}) { + return { + id: CONFLICT_ID, + href: HREF, + status: 'unresolved', + resolution: null, + local_vcard: photoVCard, + remote_vcard: remoteVCard, + local_tombstone: false, + remote_tombstone: false, + created_at: new Date('2026-07-01T00:00:00.000Z'), + updated_at: new Date('2026-07-02T00:00:00.000Z'), + resolved_at: null, + config: { + serverUrl: 'https://dav.example.test/', + username: 'private-user', + password: 'encrypted-secret', + }, + ...overrides, + }; +} + +function resolutionRow(overrides = {}) { + return conflictRow({ + address_book_id: BOOK_ID, + mapping_revision: '7', + mapping_status: 'conflict', + contact_id: '00000000-0000-4000-8000-000000000004', + contact_uid: 'contact-1', + contact_etag: 'local-etag-before', + local_address_book_id: LOCAL_BOOK_ID, + remote_book_url: 'https://dav.example.test/books/default/', + connection_generation: 'generation-current', + config: { + serverUrl: 'https://dav.example.test/', + username: 'carddav-user', + password: 'encrypted-password', + connectionGeneration: 'generation-current', + }, + ...overrides, + }); +} + +function resolutionClient(state = resolutionRow()) { + return { + query: vi.fn(async (sql, params) => { + if (/FROM carddav_conflicts[\s\S]+FOR UPDATE/.test(sql)) { + return { rows: [state], rowCount: 1 }; + } + if (/SELECT local_vcard, local_tombstone/.test(sql)) { + return { + rows: [{ + local_vcard: state.local_vcard, + local_tombstone: state.local_tombstone, + }], + rowCount: 1, + }; + } + if (/UPDATE carddav_conflicts/.test(sql)) { + return { + rows: [{ ...state, status: 'resolved', resolution: params[1] }], + rowCount: 1, + }; + } + if (/UPDATE carddav_remote_objects/.test(sql)) { + return { rows: [{ mapping_revision: '8' }], rowCount: 1 }; + } + if (/DELETE FROM carddav_remote_objects/.test(sql)) { + return { rows: [{ mapping_revision: '7' }], rowCount: 1 }; + } + if (/INSERT INTO carddav_conflicts/.test(sql)) { + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + if (/UPDATE contacts SET/.test(sql)) { + return { rows: [{ id: state.contact_id }], rowCount: 1 }; + } + if (/INSERT INTO contacts/.test(sql)) { + return { + rows: [{ id: '00000000-0000-4000-8000-000000000005' }], + rowCount: 1, + }; + } + if (/DELETE FROM contacts/.test(sql)) return { rows: [], rowCount: 1 }; + if (/UPDATE address_books/.test(sql)) return { rows: [], rowCount: 1 }; + if (/DELETE FROM carddav_conflicts/.test(sql)) return { rows: [], rowCount: 0 }; + return { rows: [], rowCount: 0 }; + }), + }; +} + +function conflictClient() { + return { + query: vi.fn(async sql => ({ + rows: sql.includes('INSERT INTO carddav_conflicts') + ? [{ id: CONFLICT_ID, status: 'unresolved' }] + : [], + rowCount: 1, + })), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('conflict reads', () => { + it('lists only conflicts owned through the CardDAV integration and mapping', async () => { + mocks.query.mockResolvedValueOnce({ rows: [conflictRow()] }); + + expect(conflictService.listConflicts).toBeTypeOf('function'); + const conflicts = await conflictService.listConflicts(USER_ID); + + expect(mocks.query).toHaveBeenCalledOnce(); + expect(mocks.query.mock.calls[0][0]).toMatch( + /FROM carddav_conflicts[\s\S]+JOIN carddav_remote_objects[\s\S]+JOIN user_integrations/, + ); + expect(mocks.query.mock.calls[0][1]).toEqual([USER_ID]); + expect(conflicts).toEqual([{ + id: CONFLICT_ID, + href: HREF, + status: 'unresolved', + resolution: null, + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-02T00:00:00.000Z', + resolvedAt: null, + local: { + tombstone: false, + hasPhoto: true, + contact: expect.objectContaining({ + uid: 'contact-1', + displayName: 'Ada Lovelace', + }), + }, + remote: { + tombstone: false, + hasPhoto: false, + contact: expect.objectContaining({ + uid: 'contact-1', + displayName: 'Ada Byron', + }), + }, + }]); + expect(JSON.stringify(conflicts)).not.toMatch( + /private-user|encrypted-secret|serverUrl|password|photoData|BEGIN:VCARD/, + ); + }); + + it('returns no detail when the integration-plus-mapping ownership chain does not match', async () => { + mocks.query.mockResolvedValueOnce({ rows: [] }); + + expect(conflictService.getConflict).toBeTypeOf('function'); + await expect(conflictService.getConflict(OTHER_USER_ID, CONFLICT_ID)).resolves.toBeNull(); + + expect(mocks.query.mock.calls[0][0]).toMatch( + /FROM carddav_conflicts[\s\S]+JOIN carddav_remote_objects[\s\S]+JOIN user_integrations/, + ); + expect(mocks.query.mock.calls[0][1]).toEqual([OTHER_USER_ID, CONFLICT_ID]); + }); + + it('represents tombstones without parsing or exposing a raw snapshot', async () => { + mocks.query.mockResolvedValueOnce({ + rows: [conflictRow({ + local_vcard: null, + local_tombstone: true, + })], + }); + + const conflict = await conflictService.getConflict(USER_ID, CONFLICT_ID); + + expect(conflict.local).toEqual({ + tombstone: true, + hasPhoto: false, + contact: null, + }); + }); + + it('reports URI-backed PHOTO presence without exposing or fetching its value', async () => { + mocks.query.mockResolvedValueOnce({ + rows: [conflictRow({ remote_vcard: uriPhotoVCard })], + }); + + const conflict = await conflictService.getConflict(USER_ID, CONFLICT_ID); + + expect(conflict.remote).toMatchObject({ + tombstone: false, + hasPhoto: true, + contact: expect.not.objectContaining({ photoData: expect.anything() }), + }); + expect(JSON.stringify(conflict.remote)).not.toMatch( + /images\.example\.test|private\.jpg|BEGIN:VCARD/, + ); + }); +}); + +describe('conflict resolution validation', () => { + it.each([ + undefined, + null, + '', + 'keep-mailflow ', + 'KEEP-CARDDAV', + 'merge', + ])('rejects non-enum resolution %j before database or remote I/O', async resolution => { + expect(conflictService.resolveConflict).toBeTypeOf('function'); + + await expect(conflictService.resolveConflict(USER_ID, CONFLICT_ID, resolution)) + .rejects.toMatchObject({ code: 'ERR_CARDDAV_CONFLICT_RESOLUTION' }); + + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.withTransaction).not.toHaveBeenCalled(); + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + }); + + it('rejects an already-resolved transition as stale before remote I/O', async () => { + mocks.query.mockResolvedValueOnce({ + rows: [resolutionRow({ + status: 'resolved', + resolution: 'keep-carddav', + resolved_at: new Date('2026-07-03T00:00:00.000Z'), + })], + }); + + await expect(conflictService.resolveConflict( + USER_ID, + CONFLICT_ID, + 'keep-mailflow', + )).rejects.toMatchObject({ code: 'ERR_CARDDAV_CONFLICT_STALE' }); + + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + expect(mocks.withTransaction).not.toHaveBeenCalled(); + }); +}); + +describe('conflict resolution lifecycle', () => { + it('keep-mailflow uses the latest ETag, canonical GET, then one atomic resolve', async () => { + const preflight = resolutionRow(); + const client = resolutionClient(preflight); + let insideTransaction = false; + mocks.query.mockResolvedValueOnce({ rows: [preflight] }); + mocks.fetchCardResource + .mockImplementationOnce(async () => { + expect(insideTransaction).toBe(false); + return { href: HREF, etag: '"latest-before"', vcard: remoteVCard }; + }) + .mockImplementationOnce(async () => { + expect(insideTransaction).toBe(false); + return { href: HREF, etag: '"canonical-after"', vcard: photoVCard }; + }); + mocks.putCardResource.mockImplementationOnce(async () => { + expect(insideTransaction).toBe(false); + return { href: HREF, etag: '"provisional"' }; + }); + mocks.withTransaction.mockImplementationOnce(async callback => { + insideTransaction = true; + try { + return await callback(client); + } finally { + insideTransaction = false; + } + }); + + await expect(conflictService.resolveConflict( + USER_ID, + CONFLICT_ID, + 'keep-mailflow', + )).resolves.toMatchObject({ + id: CONFLICT_ID, + status: 'resolved', + resolution: 'keep-mailflow', + }); + + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.putCardResource).toHaveBeenCalledWith({ + url: preflight.remote_book_url, + href: HREF, + etag: '"latest-before"', + vcard: photoVCard, + username: 'carddav-user', + password: 'encrypted-password', + allowPrivate: false, + }); + expect(mocks.putCardResource.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.fetchCardResource.mock.invocationCallOrder[1]); + expect(mocks.fetchCardResource.mock.invocationCallOrder[1]) + .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[0]); + + const sql = client.query.mock.calls.map(([statement]) => statement); + expect(sql.some(statement => /UPDATE contacts SET/.test(statement))).toBe(true); + expect(sql.some(statement => /UPDATE carddav_remote_objects/.test(statement))).toBe(true); + expect(sql.some(statement => /UPDATE carddav_conflicts/.test(statement))).toBe(true); + }); + + it('keep-mailflow conditionally deletes a local tombstone before canonical confirmation', async () => { + const preflight = resolutionRow({ + local_vcard: null, + local_tombstone: true, + }); + const client = resolutionClient(preflight); + mocks.query.mockResolvedValueOnce({ rows: [preflight] }); + mocks.fetchCardResource + .mockResolvedValueOnce({ href: HREF, etag: '"latest-before"', vcard: remoteVCard }) + .mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 })); + mocks.withTransaction.mockImplementationOnce(callback => callback(client)); + + await conflictService.resolveConflict(USER_ID, CONFLICT_ID, 'keep-mailflow'); + + expect(mocks.deleteCardResource).toHaveBeenCalledWith({ + url: preflight.remote_book_url, + href: HREF, + etag: '"latest-before"', + username: 'carddav-user', + password: 'encrypted-password', + allowPrivate: false, + }); + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + expect(client.query.mock.calls.some(([sql]) => /DELETE FROM contacts/.test(sql))).toBe(true); + }); + + it('keep-carddav fetches a fresh snapshot before applying and resolving locally', async () => { + const preflight = resolutionRow(); + const client = resolutionClient(preflight); + const losslessRemoteVCard = remoteVCard + .replace('UID:contact-1', 'UID:remote-contact') + .replace('END:VCARD\r\n', 'X-CUSTOM-KEEP;VALUE=TEXT:x\r\nEND:VCARD\r\n'); + let insideTransaction = false; + mocks.query.mockResolvedValueOnce({ rows: [preflight] }); + mocks.fetchCardResource.mockImplementationOnce(async () => { + expect(insideTransaction).toBe(false); + return { href: HREF, etag: '"fresh-remote"', vcard: losslessRemoteVCard }; + }); + mocks.withTransaction.mockImplementationOnce(async callback => { + insideTransaction = true; + try { + return await callback(client); + } finally { + insideTransaction = false; + } + }); + + await expect(conflictService.resolveConflict( + USER_ID, + CONFLICT_ID, + 'keep-carddav', + )).resolves.toMatchObject({ + id: CONFLICT_ID, + status: 'resolved', + resolution: 'keep-carddav', + }); + + expect(mocks.fetchCardResource).toHaveBeenCalledOnce(); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + expect(mocks.deleteCardResource).not.toHaveBeenCalled(); + expect(mocks.fetchCardResource.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[0]); + const contactUpdate = client.query.mock.calls.find(([sql]) => /UPDATE contacts SET/.test(sql)); + expect(contactUpdate[1][1]).toContain('UID:contact-1'); + expect(contactUpdate[1][1]).toContain('X-CUSTOM-KEEP;VALUE=TEXT:x'); + expect(contactUpdate[1]).toEqual(expect.arrayContaining([ + 'Ada Byron', preflight.contact_id, USER_ID, + ])); + }); + + it('keep-carddav recreates a missing local tombstone from the fresh snapshot', async () => { + const preflight = resolutionRow({ + contact_id: null, + contact_uid: null, + contact_etag: null, + local_address_book_id: null, + local_vcard: null, + local_tombstone: true, + }); + const client = resolutionClient(preflight); + mocks.query.mockResolvedValueOnce({ rows: [preflight] }); + mocks.fetchCardResource.mockResolvedValueOnce({ + href: HREF, + etag: '"fresh-remote"', + vcard: remoteVCard, + }); + mocks.withTransaction.mockImplementationOnce(callback => callback(client)); + + await conflictService.resolveConflict(USER_ID, CONFLICT_ID, 'keep-carddav'); + + const insert = client.query.mock.calls.find(([sql]) => /INSERT INTO contacts/.test(sql)); + expect(insert).toBeDefined(); + expect(insert[1].slice(0, 2)).toEqual([BOOK_ID, USER_ID]); + const mapping = client.query.mock.calls.find(([sql]) => ( + /UPDATE carddav_remote_objects/.test(sql) + )); + expect(mapping[1]).toContain('00000000-0000-4000-8000-000000000005'); + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching(/UPDATE address_books/), + [BOOK_ID, USER_ID], + ); + }); + + it('refreshes the same unresolved conflict after a concurrent 412', async () => { + const preflight = resolutionRow(); + const client = resolutionClient(preflight); + const concurrentVCard = remoteVCard.replace('FN:Ada Byron', 'FN:Concurrent Remote'); + mocks.query.mockResolvedValueOnce({ rows: [preflight] }); + mocks.fetchCardResource + .mockResolvedValueOnce({ href: HREF, etag: '"latest-before"', vcard: remoteVCard }) + .mockResolvedValueOnce({ href: HREF, etag: '"concurrent"', vcard: concurrentVCard }); + mocks.putCardResource.mockRejectedValueOnce( + Object.assign(new Error('Precondition failed'), { status: 412 }), + ); + mocks.withTransaction.mockImplementationOnce(callback => callback(client)); + + await expect(conflictService.resolveConflict( + USER_ID, + CONFLICT_ID, + 'keep-mailflow', + )).rejects.toMatchObject({ + code: 'ERR_CARDDAV_CONFLICT_STALE', + conflictId: CONFLICT_ID, + }); + + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + const refresh = client.query.mock.calls.find(([sql]) => ( + /INSERT INTO carddav_conflicts/.test(sql) + )); + expect(refresh[1]).toEqual(expect.arrayContaining([ + BOOK_ID, + HREF, + USER_ID, + '"concurrent"', + concurrentVCard, + ])); + expect(client.query.mock.calls.some(([sql]) => ( + /UPDATE carddav_conflicts[\s\S]+status = 'resolved'/.test(sql) + ))).toBe(false); + }); +}); + +describe('resolved conflict cleanup', () => { + it('deletes only resolved rows strictly older than the supplied cutoff', async () => { + const cutoff = new Date('2026-06-12T12:00:00.000Z'); + const client = { + query: vi.fn(async () => ({ rows: [], rowCount: 3 })), + }; + + expect(conflictService.deleteResolvedConflictsBefore).toBeTypeOf('function'); + await expect(conflictService.deleteResolvedConflictsBefore(client, cutoff)).resolves.toBe(3); + + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /DELETE FROM carddav_conflicts[\s\S]+status = 'resolved'[\s\S]+resolved_at < \$1/, + ), + [cutoff], + ); + expect(client.query.mock.calls[0][0]).not.toMatch(/status = 'unresolved'/); + }); +}); + +describe('conflict snapshot limits', () => { + it('allows exactly 2 MiB of non-tombstone UTF-8 snapshots', async () => { + await expect(recordCarddavConflict(conflictClient(), { + addressBookId: BOOK_ID, + href: 'https://dav.example.test/books/default/contact.vcf', + expectedMappingRevision: '7', + userId: USER_ID, + localVCard: '🙂'.repeat(256 * 1024), + remoteVCard: '🙂'.repeat(256 * 1024), + })).resolves.toMatchObject({ id: CONFLICT_ID }); + }); + + it('rejects 2 MiB + 1 before any conflict query', async () => { + const client = conflictClient(); + + await expect(recordCarddavConflict(client, { + addressBookId: BOOK_ID, + href: 'https://dav.example.test/books/default/contact.vcf', + expectedMappingRevision: '7', + userId: USER_ID, + localVCard: '🙂'.repeat(256 * 1024), + remoteVCard: '🙂'.repeat(256 * 1024) + 'x', + })).rejects.toMatchObject({ code: 'ERR_CARDDAV_CONFLICT_TOO_LARGE' }); + + expect(client.query).not.toHaveBeenCalled(); + }); + + it.each([ + ['local', { + localVCard: 'a'.repeat(2 * 1024 * 1024 + 1), + remoteVCard: 'remote', + localTombstone: true, + }], + ['remote', { + localVCard: 'local', + remoteVCard: 'b'.repeat(2 * 1024 * 1024 + 1), + remoteTombstone: true, + }], + ])('excludes a %s tombstone snapshot from the byte total', async (_side, snapshots) => { + await expect(recordCarddavConflict(conflictClient(), { + addressBookId: BOOK_ID, + href: 'https://dav.example.test/books/default/contact.vcf', + expectedMappingRevision: '7', + userId: USER_ID, + ...snapshots, + })).resolves.toMatchObject({ id: CONFLICT_ID }); + }); +}); + +describe('recordCarddavConflict', () => { + it('requires the caller to supply the locked mapping revision', async () => { + const client = conflictClient(); + + await expect(recordCarddavConflict(client, { + addressBookId: BOOK_ID, + href: 'https://dav.example.test/books/default/contact.vcf', + userId: USER_ID, + localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n', + remoteVCard: 'BEGIN:VCARD\r\nFN:Remote\r\nEND:VCARD\r\n', + })).rejects.toMatchObject({ code: 'ERR_CARDDAV_MAPPING_REVISION_REQUIRED' }); + + expect(client.query).not.toHaveBeenCalled(); + }); + + it('upserts the one unresolved conflict and marks its mapping conflicted', async () => { + const conflict = { + addressBookId: BOOK_ID, + href: 'https://dav.example.test/books/default/contact.vcf', + expectedMappingRevision: '7', + userId: USER_ID, + baseLocalHash: 'base-hash', + remoteEtag: '"remote-2"', + localVCard: 'BEGIN:VCARD\r\nFN:Rejected\r\nEND:VCARD\r\n', + remoteVCard: 'BEGIN:VCARD\r\nFN:Remote\r\nEND:VCARD\r\n', + localTombstone: false, + remoteTombstone: false, + }; + const client = { + query: vi.fn(async sql => { + if (sql.includes('INSERT INTO carddav_conflicts')) { + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }), + }; + + await expect(recordCarddavConflict(client, conflict)).resolves.toEqual({ + id: CONFLICT_ID, + status: 'unresolved', + }); + + const insert = client.query.mock.calls.find(([sql]) => ( + sql.includes('INSERT INTO carddav_conflicts') + )); + expect(insert[0]).toContain("ON CONFLICT (address_book_id, href) WHERE status = 'unresolved'"); + expect(insert[1]).toEqual([ + BOOK_ID, + conflict.href, + USER_ID, + 'base-hash', + '"remote-2"', + conflict.localVCard, + conflict.remoteVCard, + false, + false, + ]); + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /UPDATE carddav_remote_objects[\s\S]+mapping_status = 'conflict'[\s\S]+mapping_revision = mapping_revision \+ 1/, + ), + [BOOK_ID, conflict.href, '7'], + ); + }); + + it('records deletion snapshots with explicit tombstones', async () => { + const client = { + query: vi.fn(async sql => ({ + rows: sql.includes('INSERT INTO carddav_conflicts') + ? [{ id: CONFLICT_ID, status: 'unresolved' }] + : [], + rowCount: 1, + })), + }; + + await recordCarddavConflict(client, { + addressBookId: BOOK_ID, + href: 'https://dav.example.test/books/default/contact.vcf', + expectedMappingRevision: '7', + userId: USER_ID, + baseLocalHash: 'base-hash', + remoteEtag: null, + localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n', + remoteVCard: null, + localTombstone: false, + remoteTombstone: true, + }); + + const insert = client.query.mock.calls.find(([sql]) => ( + sql.includes('INSERT INTO carddav_conflicts') + )); + expect(insert[1].slice(-2)).toEqual([false, true]); + }); + + it('refreshes the caller-supplied local snapshot on repeated wrapper calls', async () => { + const href = 'https://dav.example.test/books/default/repeated.vcf'; + const inserts = []; + let storedLocal = null; + const client = { + query: vi.fn(async (sql, params) => { + if (sql.includes('SELECT local_vcard, local_tombstone')) { + return { rows: storedLocal ? [storedLocal] : [], rowCount: Number(Boolean(storedLocal)) }; + } + if (sql.includes('UPDATE carddav_remote_objects')) { + return { rows: [{ mapping_revision: inserts.length === 0 ? '8' : '9' }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_conflicts')) { + inserts.push([sql, params]); + storedLocal ??= { local_vcard: params[5], local_tombstone: params[7] }; + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }), + }; + const shared = { + addressBookId: BOOK_ID, + href, + userId: USER_ID, + baseLocalHash: 'base-hash', + remoteEtag: '"remote"', + remoteVCard: 'BEGIN:VCARD\r\nFN:Remote\r\nEND:VCARD\r\n', + remoteTombstone: false, + }; + + await recordCarddavConflict(client, { + ...shared, + expectedMappingRevision: '7', + localVCard: 'BEGIN:VCARD\r\nFN:First Local\r\nEND:VCARD\r\n', + localTombstone: false, + }); + await recordCarddavConflict(client, { + ...shared, + expectedMappingRevision: '8', + localVCard: null, + localTombstone: true, + }); + + expect(inserts).toHaveLength(2); + expect(inserts[1][0]).toMatch(/local_vcard = EXCLUDED\.local_vcard/); + expect(inserts[1][0]).toMatch(/local_tombstone = EXCLUDED\.local_tombstone/); + expect(inserts[1][1].slice(5, 9)).toEqual([ + null, + shared.remoteVCard, + true, + false, + ]); + }); +}); diff --git a/backend/src/services/carddavContactService.integration.test.js b/backend/src/services/carddavContactService.integration.test.js new file mode 100644 index 00000000..24a17150 --- /dev/null +++ b/backend/src/services/carddavContactService.integration.test.js @@ -0,0 +1,1252 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createCarddavFixtureServer } from './carddavFixtureServer.js'; +import { + assertMinimumPostgresVersion, + createTestDatabase, + dropTestDatabase, + postgresTestContext, + productionDatabaseEnvironment, +} from './postgresTestHelpers.js'; +import { generateVCard } from '../utils/vcard.js'; +import { + contactFromVCardDocument, + localContactHash, + parseVCardDocument, + semanticVCardHash, +} from '../utils/vcardProperties.js'; + +const { Client } = pg; +const { databaseUrl, connectionStringFor } = postgresTestContext( + 'CardDAV contact integration tests', +); + +const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations'); +const databaseName = `carddav_contacts_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`; +const encryptionKey = '22'.repeat(32); +const productionEnvironment = productionDatabaseEnvironment(encryptionKey); +const credentials = { + username: 'fixture-user', + password: 'fixture-password', +}; +const MAX_VCARD_BYTES = 1024 * 1024; +const MAX_CONTENT_LINE_BYTES = 64 * 1024; + +let adminClient; +let databaseClient; +let productionDb; +let contactService; +let contactsRouter; +let encrypt; +let activeFixture; +let beforeBegin; +let afterCommit; +let nextInstrumentedClientId = 1; +let transactionEvents = []; + +async function instrumentTransactions() { + const instrumentClient = client => { + if (client.carddavContactOriginalQuery) return client; + + client.carddavContactOriginalQuery = client.query.bind(client); + const clientId = nextInstrumentedClientId++; + client.query = (text, ...args) => { + if (typeof args.at(-1) === 'function') { + return client.carddavContactOriginalQuery(text, ...args); + } + const sql = typeof text === 'string' ? text.trim().toUpperCase() : ''; + return (async () => { + if (sql === 'BEGIN' && beforeBegin) await beforeBegin(); + const result = await client.carddavContactOriginalQuery(text, ...args); + if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') { + transactionEvents.push({ + clientId, + sql, + networkRequests: activeFixture?.counters.requests ?? 0, + }); + } + if (sql === 'COMMIT' && afterCommit) await afterCommit(); + return result; + })(); + }; + return client; + }; + productionDb.pool.on('connect', instrumentClient); + const client = await productionDb.pool.connect(); + instrumentClient(client); + client.release(); +} + +function resetObservation(fixture) { + activeFixture = fixture; + beforeBegin = null; + afterCommit = null; + transactionEvents = []; + fixture?.reset(); +} + +function expectNoNetworkInsideTransactions() { + const openTransactions = new Map(); + let completed = 0; + for (const event of transactionEvents) { + if (event.sql === 'BEGIN') { + expect(openTransactions.has(event.clientId)).toBe(false); + openTransactions.set(event.clientId, event); + continue; + } + const openTransaction = openTransactions.get(event.clientId); + expect(openTransaction).toBeDefined(); + expect(event.networkRequests).toBe(openTransaction.networkRequests); + openTransactions.delete(event.clientId); + completed++; + } + expect(openTransactions.size).toBe(0); + expect(completed).toBeGreaterThan(0); +} + +function vcard(uid, displayName, email = `${uid}@example.test`) { + return [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${uid}`, + `FN:${displayName}`, + `EMAIL:${email}`, + 'END:VCARD', + '', + ].join('\r\n'); +} + +function sizedVCard(uid, size) { + const start = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${uid}\r\nFN:Limit\r\n`; + const end = 'END:VCARD\r\n'; + let remaining = size - Buffer.byteLength(start) - Buffer.byteLength(end); + const lines = []; + + while (remaining > 0) { + let lineBytes = Math.min(MAX_CONTENT_LINE_BYTES + 2, remaining); + const tail = remaining - lineBytes; + if (tail > 0 && tail < 4) lineBytes -= 4 - tail; + lines.push(`X:${'a'.repeat(lineBytes - 4)}\r\n`); + remaining -= lineBytes; + } + + return start + lines.join('') + end; +} + +function draft(displayName, email) { + return { + displayName, + firstName: displayName.split(' ')[0], + lastName: displayName.split(' ').slice(1).join(' ') || null, + emails: [{ value: email, type: 'work', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [], + }; +} + +async function authoritativeState(userId) { + const { rows: addressBooks } = await databaseClient.query(` + SELECT id, name, source, external_url, sync_token, + remote_create_capability, remote_update_capability, remote_delete_capability + FROM address_books + WHERE user_id = $1 + ORDER BY source, external_url NULLS FIRST, id + `, [userId]); + const { rows: contacts } = await databaseClient.query(` + SELECT id, address_book_id, user_id, uid, vcard, etag, display_name, + first_name, last_name, primary_email, emails, phones, organization, + notes, photo_data, additional_fields, is_auto, send_count, last_sent + FROM contacts + WHERE user_id = $1 + ORDER BY address_book_id, uid + `, [userId]); + const { rows: remoteObjects } = await databaseClient.query(` + SELECT remote.address_book_id, remote.href, remote.remote_etag, remote.vcard, + remote.primary_email, remote.local_contact_id, + remote.mapping_status, remote.vcard_version, + remote.remote_semantic_hash, remote.local_contact_hash, + remote.mapping_revision::text + FROM carddav_remote_objects remote + JOIN address_books book ON book.id = remote.address_book_id + WHERE book.user_id = $1 + ORDER BY remote.address_book_id, remote.href + `, [userId]); + const { rows: conflicts } = await databaseClient.query(` + SELECT id, address_book_id, href, user_id, base_local_hash, remote_etag, + local_vcard, remote_vcard, local_tombstone, remote_tombstone, + status, resolution, resolved_by, resolved_at + FROM carddav_conflicts + WHERE user_id = $1 + ORDER BY address_book_id, href + `, [userId]); + return { addressBooks, contacts, remoteObjects, conflicts }; +} + +async function pendingIntentState(userId) { + const { rows } = await databaseClient.query(` + SELECT remote.href, remote.pending_operation, remote.pending_vcard, + remote.pending_local_hash, remote.pending_remote_semantic_hash, + remote.pending_started_at IS NOT NULL AS pending_started + FROM carddav_remote_objects remote + JOIN address_books book ON book.id = remote.address_book_id + WHERE book.user_id = $1 AND remote.pending_operation IS NOT NULL + ORDER BY remote.address_book_id, remote.href + `, [userId]); + return rows; +} + +async function seedUser() { + const userId = randomUUID(); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-contact-${userId}`], + ); + return userId; +} + +async function seedConnectedUser(fixture) { + const userId = await seedUser(); + const connectionGeneration = randomUUID(); + const { rows: [localBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Personal') RETURNING id", + [userId], + ); + await databaseClient.query(` + INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb) + `, [userId, JSON.stringify({ + serverUrl: fixture.serverUrl, + username: credentials.username, + password: encrypt(credentials.password), + connectionGeneration, + })]); + return { userId, connectionGeneration, localBookId: localBook.id }; +} + +async function seedMappedContact(fixture, name = 'Mapped Before') { + const seeded = await seedConnectedUser(fixture); + const uid = randomUUID(); + const rawVCard = vcard(uid, name); + const href = fixture.href(`${uid}.vcf`); + const remoteEtag = '"mapped-1"'; + const { rows: [remoteBook] } = await databaseClient.query(` + INSERT INTO address_books ( + user_id, name, source, external_url, + remote_create_capability, remote_update_capability, remote_delete_capability + ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed') + RETURNING id + `, [seeded.userId, new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href]); + const { rows: [contact] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, additional_fields, is_auto + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,'[]'::jsonb,'[]'::jsonb,false) + RETURNING id, etag + `, [ + seeded.localBookId, + seeded.userId, + uid, + rawVCard, + createHash('md5').update(rawVCard).digest('hex'), + name, + `${uid}@example.test`, + JSON.stringify([{ value: `${uid}@example.test`, type: 'other', primary: true }]), + ]); + await databaseClient.query(` + INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, + local_contact_id, mapping_status, vcard_version, + remote_semantic_hash, local_contact_hash, last_synced_at + ) VALUES ($1,$2,$3,$4,$5,$6,'synced','3.0','remote-hash','local-hash',NOW()) + `, [ + remoteBook.id, + href, + remoteEtag, + rawVCard, + `${uid}@example.test`, + contact.id, + ]); + fixture.putContact(href, remoteEtag, rawVCard); + return { ...seeded, uid, href, remoteEtag, remoteBookId: remoteBook.id, contact }; +} + +// Reproduce the state a sync pull leaves behind: the local contacts row holds the +// LOSSY re-serialized vCard (generateVCard drops unmodeled properties) while the +// carddav_remote_objects row retains the FULL remote vCard. The local UID is the +// href hash, exactly as desiredAutomaticContact derives it. +async function seedImportedContact(fixture, remoteVCard, remoteEtag = '"imported-1"') { + const seeded = await seedConnectedUser(fixture); + const href = fixture.href(`${randomUUID()}.vcf`); + const document = parseVCardDocument(remoteVCard); + const projected = contactFromVCardDocument(document); + const uid = createHash('sha256').update(href).digest('hex'); + const primaryEmail = projected.emails?.[0]?.value ?? null; + const desired = { + ...projected, + uid, + primaryEmail, + additionalFields: projected.additionalFields || [], + }; + const localVCard = generateVCard(desired); + const localEtag = createHash('md5').update(localVCard).digest('hex'); + const { rows: [remoteBook] } = await databaseClient.query(` + INSERT INTO address_books ( + user_id, name, source, external_url, + remote_create_capability, remote_update_capability, remote_delete_capability + ) VALUES ($1, 'Fixture Contacts', 'carddav', $2, 'allowed', 'allowed', 'allowed') + RETURNING id + `, [seeded.userId, new URL('/addressbooks/fixture-user/contacts/', fixture.serverUrl).href]); + const { rows: [contact] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + first_name, last_name, primary_email, emails, phones, + organization, notes, additional_fields, is_auto + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14::jsonb,false) + RETURNING id, etag + `, [ + seeded.localBookId, + seeded.userId, + uid, + localVCard, + localEtag, + desired.displayName, + desired.firstName, + desired.lastName, + primaryEmail, + JSON.stringify(desired.emails || []), + JSON.stringify(desired.phones || []), + desired.organization, + desired.notes, + JSON.stringify(desired.additionalFields || []), + ]); + await databaseClient.query(` + INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, + local_contact_id, mapping_status, vcard_version, + remote_semantic_hash, local_contact_hash, last_synced_at + ) VALUES ($1,$2,$3,$4,$5,$6,'synced',$7,$8,$9,NOW()) + `, [ + remoteBook.id, + href, + remoteEtag, + remoteVCard, + primaryEmail, + contact.id, + document.version, + semanticVCardHash(document), + localContactHash(desired), + ]); + fixture.putContact(href, remoteEtag, remoteVCard); + return { + ...seeded, + uid, + href, + remoteEtag, + remoteBookId: remoteBook.id, + contact, + localVCard, + remoteVCard, + }; +} + +describe('CardDAV contact mutations against PostgreSQL 16 and HTTP', () => { + beforeAll(async () => { + adminClient = new Client({ connectionString: databaseUrl }); + await adminClient.connect(); + await createTestDatabase(adminClient, databaseName); + + const connectionString = connectionStringFor(databaseName); + databaseClient = new Client({ connectionString }); + await databaseClient.connect(); + await assertMinimumPostgresVersion(databaseClient); + + productionEnvironment.configure(connectionString); + productionDb = await import('./db.js'); + const { runMigrationsWithPool } = await import('./migrations.js'); + await runMigrationsWithPool(productionDb.pool, migrationsDirectory); + await productionDb.pool.query(` + INSERT INTO system_settings (key, value) VALUES ('allow_private_hosts', 'true') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `); + ({ encrypt } = await import('./encryption.js')); + contactService = await import('./carddavContactService.js'); + ({ default: contactsRouter } = await import('../routes/contacts.js')); + await instrumentTransactions(); + }, 120_000); + + beforeEach(() => { + activeFixture = null; + beforeBegin = null; + afterCommit = null; + transactionEvents = []; + }); + + afterAll(async () => { + activeFixture = null; + await productionDb?.pool.end(); + await databaseClient?.end(); + if (adminClient) { + await dropTestDatabase(adminClient, databaseName); + await adminClient.end(); + } + productionEnvironment.restore(); + }, 120_000); + + it('executes local create, update, and delete SQL with authoritative read-back', async () => { + const userId = await seedUser(); + resetObservation(null); + + const created = await contactService.createContact( + userId, + draft('Local Created', 'local@example.test'), + ); + const afterCreate = await authoritativeState(userId); + expect(afterCreate.addressBooks).toHaveLength(1); + expect(afterCreate.contacts).toEqual([ + expect.objectContaining({ + id: created.id, + address_book_id: afterCreate.addressBooks[0].id, + user_id: userId, + uid: created.uid, + display_name: 'Local Created', + primary_email: 'local@example.test', + is_auto: false, + }), + ]); + expect(afterCreate.remoteObjects).toEqual([]); + expect(afterCreate.conflicts).toEqual([]); + expectNoNetworkInsideTransactions(); + + resetObservation(null); + const updated = await contactService.updateContact( + userId, + created.id, + draft('Local Updated', 'updated@example.test'), + ); + const afterUpdate = await authoritativeState(userId); + expect(updated.display_name).toBe('Local Updated'); + expect(afterUpdate.contacts).toEqual([ + expect.objectContaining({ + id: created.id, + display_name: 'Local Updated', + primary_email: 'updated@example.test', + }), + ]); + expect(afterUpdate.addressBooks[0].sync_token) + .not.toBe(afterCreate.addressBooks[0].sync_token); + expect(afterUpdate.remoteObjects).toEqual([]); + expect(afterUpdate.conflicts).toEqual([]); + expectNoNetworkInsideTransactions(); + + resetObservation(null); + await expect(contactService.deleteContact(userId, created.id)) + .resolves.toEqual({ ok: true }); + const afterDelete = await authoritativeState(userId); + expect(afterDelete.addressBooks).toHaveLength(1); + expect(afterDelete.addressBooks[0].sync_token) + .not.toBe(afterUpdate.addressBooks[0].sync_token); + expect(afterDelete.contacts).toEqual([]); + expect(afterDelete.remoteObjects).toEqual([]); + expect(afterDelete.conflicts).toEqual([]); + expectNoNetworkInsideTransactions(); + }); + + it('executes mapped vCard create, replace, and delete with conditional fixture HTTP', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const { userId, localBookId } = await seedConnectedUser(fixture); + const uid = randomUUID(); + + try { + resetObservation(fixture); + const created = await contactService.createContactFromVCard(userId, { + localAddressBookId: localBookId, + uid, + rawVCard: vcard(uid, 'Remote Created'), + }); + const afterCreate = await authoritativeState(userId); + expect(afterCreate.addressBooks).toHaveLength(2); + expect(afterCreate.contacts).toEqual([ + expect.objectContaining({ + id: created.id, + address_book_id: localBookId, + uid, + display_name: 'Remote Created', + }), + ]); + expect(afterCreate.remoteObjects).toEqual([ + expect.objectContaining({ + local_contact_id: created.id, + mapping_status: 'synced', + mapping_revision: '0', + }), + ]); + expect(afterCreate.conflicts).toEqual([]); + expect(fixture.counters).toMatchObject({ + requests: 5, + propfind: 3, + create: 1, + update: 0, + delete: 0, + fetch: 1, + }); + const createRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(createRequest).toMatchObject({ + ifMatch: undefined, + ifNoneMatch: '*', + }); + expectNoNetworkInsideTransactions(); + + resetObservation(fixture); + const beforeReplace = await authoritativeState(userId); + const expectedRemoteEtag = beforeReplace.remoteObjects[0].remote_etag; + const replaced = await contactService.replaceContactFromVCard(userId, { + localAddressBookId: localBookId, + uid, + rawVCard: vcard(uid, 'Remote Replaced'), + expectedLocalEtag: beforeReplace.contacts[0].etag, + }); + const afterReplace = await authoritativeState(userId); + expect(replaced.display_name).toBe('Remote Replaced'); + expect(afterReplace.contacts).toEqual([ + expect.objectContaining({ + id: created.id, + display_name: 'Remote Replaced', + }), + ]); + expect(afterReplace.remoteObjects).toEqual([ + expect.objectContaining({ + local_contact_id: created.id, + mapping_status: 'synced', + mapping_revision: '2', + }), + ]); + expect(afterReplace.conflicts).toEqual([]); + expect(await pendingIntentState(userId)).toEqual([]); + expect(fixture.counters).toMatchObject({ + requests: 2, + create: 0, + update: 1, + delete: 0, + fetch: 1, + }); + const updateRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(updateRequest).toMatchObject({ + ifMatch: expectedRemoteEtag, + ifNoneMatch: undefined, + }); + expectNoNetworkInsideTransactions(); + + resetObservation(fixture); + const expectedDeleteEtag = afterReplace.remoteObjects[0].remote_etag; + await expect(contactService.deleteContactFromVCard(userId, { + localAddressBookId: localBookId, + uid, + expectedLocalEtag: afterReplace.contacts[0].etag, + })).resolves.toEqual({ ok: true }); + const afterDelete = await authoritativeState(userId); + expect(afterDelete.addressBooks).toHaveLength(2); + expect(afterDelete.contacts).toEqual([]); + expect(afterDelete.remoteObjects).toEqual([]); + expect(afterDelete.conflicts).toEqual([]); + expect(fixture.counters).toMatchObject({ + requests: 2, + create: 0, + update: 0, + delete: 1, + fetch: 1, + }); + const deleteRequest = fixture.requests.find(request => request.method === 'DELETE'); + expect(deleteRequest.ifMatch).toBe(expectedDeleteEtag); + expectNoNetworkInsideTransactions(); + } finally { + await fixture.close(); + } + }, 120_000); + + it('atomically resolves concurrent mapped create-only requests for one book and UID', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const { userId, localBookId } = await seedConnectedUser(fixture); + const uid = randomUUID(); + let preflightCommits = 0; + let releasePreflightCommits; + const bothPreflightCommits = new Promise(resolve => { releasePreflightCommits = resolve; }); + + try { + resetObservation(fixture); + afterCommit = async () => { + preflightCommits++; + if (preflightCommits === 2) { + afterCommit = null; + releasePreflightCommits(); + } + await bothPreflightCommits; + }; + const createOnly = () => contactService.createContactFromVCard(userId, { + localAddressBookId: localBookId, + uid, + rawVCard: vcard(uid, 'Concurrent Remote Create'), + expectedAbsent: true, + }); + + const outcomes = await Promise.allSettled([createOnly(), createOnly()]); + const statuses = outcomes.map(outcome => ( + outcome.status === 'fulfilled' + ? 201 + : outcome.reason.code === 'ERR_LOCAL_PRECONDITION_FAILED' ? 412 : null + )).sort(); + const after = await authoritativeState(userId); + const putRequests = fixture.requests.filter(request => request.method === 'PUT'); + + expect(statuses).toEqual([201, 412]); + expect(after.contacts).toEqual([ + expect.objectContaining({ address_book_id: localBookId, uid }), + ]); + expect(after.remoteObjects).toEqual([ + expect.objectContaining({ + local_contact_id: after.contacts[0].id, + href: fixture.href(`${uid}.vcf`), + mapping_status: 'synced', + }), + ]); + expect(after.conflicts).toEqual([]); + expect(fixture.counters).toMatchObject({ + requests: 9, + propfind: 6, + create: 1, + update: 0, + delete: 0, + fetch: 1, + }); + expect(putRequests).toHaveLength(2); + expect(putRequests).toEqual([ + expect.objectContaining({ + path: new URL(fixture.href(`${uid}.vcf`)).pathname, + ifMatch: undefined, + ifNoneMatch: '*', + }), + expect.objectContaining({ + path: new URL(fixture.href(`${uid}.vcf`)).pathname, + ifMatch: undefined, + ifNoneMatch: '*', + }), + ]); + expectNoNetworkInsideTransactions(); + } finally { + afterCommit = null; + await fixture.close(); + } + }, 120_000); + + it('rejects a stale local ETag before HTTP and preserves all authoritative rows', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedMappedContact(fixture); + + try { + resetObservation(fixture); + const before = await authoritativeState(seeded.userId); + await expect(contactService.replaceContactFromVCard(seeded.userId, { + localAddressBookId: seeded.localBookId, + uid: seeded.uid, + rawVCard: vcard(seeded.uid, 'Rejected Local ETag'), + expectedLocalEtag: 'stale-local-etag', + })).rejects.toMatchObject({ code: 'ERR_LOCAL_ETAG_MISMATCH' }); + expect(await authoritativeState(seeded.userId)).toEqual(before); + expect(fixture.counters.requests).toBe(0); + expectNoNetworkInsideTransactions(); + } finally { + await fixture.close(); + } + }); + + // The recovery fetch after a 412 must reject a malformed or oversized REMOTE + // snapshot before it can persist. (A replace now overlays the client body onto the + // retained remote vCard, so the pending intent is bounded by the retained document + // rather than the raw client body; the pending-intent 1 MiB and conflict-snapshot + // 2 MiB size limits are unit-tested at their exact boundaries in + // carddavMappingState.test.js.) + it.each([ + { + label: 'malformed remote snapshot', + remoteVCard: () => 'not a vCard', + expectedError: /BEGIN:VCARD/, + }, + { + label: 'oversized remote snapshot', + remoteVCard: uid => sizedVCard(uid, MAX_VCARD_BYTES + 1), + expectedError: /1 MiB/, + }, + ])('handles mapped 412 with $label before forbidden persistence', async ({ + remoteVCard, + expectedError, + }) => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedMappedContact(fixture); + const rejected = vcard(seeded.uid, 'Rejected Local'); + const latest = remoteVCard(seeded.uid); + + try { + resetObservation(fixture); + const before = await authoritativeState(seeded.userId); + fixture.putContact(seeded.href, '"remote-2"', latest); + fixture.queueWrite('PUT', { status: 412 }); + + const error = await contactService.replaceContactFromVCard(seeded.userId, { + localAddressBookId: seeded.localBookId, + uid: seeded.uid, + rawVCard: rejected, + expectedLocalEtag: seeded.contact.etag, + }).catch(value => value); + + const after = await authoritativeState(seeded.userId); + expect(error.message).toMatch(expectedError); + expect(after.addressBooks).toEqual(before.addressBooks); + expect(after.contacts).toEqual(before.contacts); + expect(after.remoteObjects).toEqual([{ + ...before.remoteObjects[0], + mapping_status: 'pending_push', + mapping_revision: '1', + }]); + // For an unmapped-property-free client body overlaid onto a matching retained + // document, the overlay reproduces the client body byte-for-byte, so the + // pending intent snapshots exactly what the client sent. + expect(await pendingIntentState(seeded.userId)).toEqual([ + expect.objectContaining({ + href: seeded.href, + pending_operation: 'update', + pending_vcard: rejected, + pending_local_hash: expect.any(String), + pending_remote_semantic_hash: expect.any(String), + pending_started: true, + }), + ]); + expect(after.conflicts).toEqual([]); + expect(fixture.counters.requests).toBe(2); + expectNoNetworkInsideTransactions(); + } finally { + await fixture.close(); + } + }, 120_000); + + it('rolls back final SQL when the mapping revision changes after remote update', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedMappedContact(fixture); + + try { + resetObservation(fixture); + const before = await authoritativeState(seeded.userId); + beforeBegin = async () => { + if (fixture.counters.update !== 1) return; + beforeBegin = null; + await databaseClient.query(` + UPDATE carddav_remote_objects + SET mapping_revision = mapping_revision + 1 + WHERE address_book_id = $1 AND href = $2 + `, [seeded.remoteBookId, seeded.href]); + }; + + await expect(contactService.updateContact( + seeded.userId, + seeded.contact.id, + draft('Rejected Mapping Revision', `${seeded.uid}@example.test`), + )).rejects.toBeInstanceOf(contactService.CardDavAmbiguousWriteError); + + const after = await authoritativeState(seeded.userId); + expect(after.addressBooks).toEqual(before.addressBooks); + expect(after.contacts).toEqual(before.contacts); + expect(after.remoteObjects).toEqual([{ + ...before.remoteObjects[0], + mapping_status: 'pending_push', + mapping_revision: String(Number(before.remoteObjects[0].mapping_revision) + 2), + }]); + expect(after.conflicts).toEqual(before.conflicts); + expect(await pendingIntentState(seeded.userId)).toEqual([ + expect.objectContaining({ + href: seeded.href, + pending_operation: 'update', + pending_vcard: expect.stringContaining('FN:Rejected Mapping Revision'), + pending_local_hash: expect.any(String), + pending_remote_semantic_hash: expect.any(String), + pending_started: true, + }), + ]); + expect(fixture.counters).toMatchObject({ + requests: 2, + update: 1, + fetch: 1, + }); + const updateRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(updateRequest.ifMatch).toBe(seeded.remoteEtag); + expectNoNetworkInsideTransactions(); + } finally { + await fixture.close(); + } + }); + + it('fences throttle rollback when the connection changes after the 429', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedMappedContact(fixture); + const replacementGeneration = randomUUID(); + + try { + resetObservation(fixture); + const before = await authoritativeState(seeded.userId); + fixture.queueWrite('PUT', { + status: 429, + headers: { 'Retry-After': '120' }, + }); + let beginCount = 0; + beforeBegin = async () => { + beginCount++; + if (beginCount !== 2) return; + beforeBegin = null; + await databaseClient.query(` + UPDATE user_integrations + SET config = jsonb_set( + config, '{connectionGeneration}', to_jsonb($2::text), true + ), updated_at = NOW() + WHERE user_id = $1 AND provider = 'carddav' + `, [seeded.userId, replacementGeneration]); + }; + + const error = await contactService.updateContact( + seeded.userId, + seeded.contact.id, + draft('Replaced During Throttle', `${seeded.uid}@example.test`), + ).catch(result => result); + + expect(error).toMatchObject({ code: 'ERR_CARDDAV_FINAL_FENCE' }); + const after = await authoritativeState(seeded.userId); + expect(after.addressBooks).toEqual(before.addressBooks); + expect(after.contacts).toEqual(before.contacts); + expect(after.remoteObjects).toEqual([{ + ...before.remoteObjects[0], + mapping_status: 'pending_push', + mapping_revision: String(Number(before.remoteObjects[0].mapping_revision) + 1), + }]); + expect(after.conflicts).toEqual(before.conflicts); + expect(await pendingIntentState(seeded.userId)).toEqual([ + expect.objectContaining({ + href: seeded.href, + pending_operation: 'update', + pending_vcard: expect.stringContaining('FN:Replaced During Throttle'), + pending_local_hash: expect.any(String), + pending_remote_semantic_hash: expect.any(String), + pending_started: true, + }), + ]); + const { rows: [integration] } = await databaseClient.query(` + SELECT config FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + `, [seeded.userId]); + expect(integration.config).toMatchObject({ + connectionGeneration: replacementGeneration, + }); + expect(integration.config).not.toHaveProperty('retryAfterAt'); + expect(fixture.requests.map(request => request.method)).toEqual(['PUT']); + expectNoNetworkInsideTransactions(); + } finally { + beforeBegin = null; + await fixture.close(); + } + }); + + it('rolls back a forced final transaction failure after one confirmed remote update', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedMappedContact(fixture); + + await databaseClient.query(` + CREATE FUNCTION fail_carddav_contact_update() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.display_name = 'Forced Final Failure' THEN + RAISE EXCEPTION 'forced final transaction failure'; + END IF; + RETURN NEW; + END + $$ + `); + await databaseClient.query(` + CREATE TRIGGER fail_carddav_contact_update + BEFORE UPDATE ON contacts + FOR EACH ROW EXECUTE FUNCTION fail_carddav_contact_update() + `); + + try { + resetObservation(fixture); + const before = await authoritativeState(seeded.userId); + await expect(contactService.updateContact( + seeded.userId, + seeded.contact.id, + draft('Forced Final Failure', `${seeded.uid}@example.test`), + )).rejects.toBeInstanceOf(contactService.CardDavAmbiguousWriteError); + + const after = await authoritativeState(seeded.userId); + expect(after.addressBooks).toEqual(before.addressBooks); + expect(after.contacts).toEqual(before.contacts); + expect(after.remoteObjects).toEqual([{ + ...before.remoteObjects[0], + mapping_status: 'pending_push', + mapping_revision: String(Number(before.remoteObjects[0].mapping_revision) + 1), + }]); + expect(after.conflicts).toEqual(before.conflicts); + expect(await pendingIntentState(seeded.userId)).toEqual([ + expect.objectContaining({ + href: seeded.href, + pending_operation: 'update', + pending_vcard: expect.stringContaining('FN:Forced Final Failure'), + pending_local_hash: expect.any(String), + pending_remote_semantic_hash: expect.any(String), + pending_started: true, + }), + ]); + expect(fixture.counters).toMatchObject({ + requests: 2, + update: 1, + fetch: 1, + }); + const updateRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(updateRequest.ifMatch).toBe(seeded.remoteEtag); + expectNoNetworkInsideTransactions(); + } finally { + await databaseClient.query('DROP TRIGGER fail_carddav_contact_update ON contacts'); + await databaseClient.query('DROP FUNCTION fail_carddav_contact_update()'); + await fixture.close(); + } + }); + + it('retains unmodeled remote vCard properties when editing a sync-imported contact', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const remoteVCard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:remote-server-uid-123', + 'FN:Imported Person', + 'EMAIL:imported@example.test', + 'CATEGORIES:Friends,VIP', + 'X-CUSTOM-FLAG:keep-me', + 'TZ:America/New_York', + 'END:VCARD', + '', + ].join('\r\n'); + const seeded = await seedImportedContact(fixture, remoteVCard); + + try { + // Sanity: the lossy local vCard dropped the unmodeled properties the pull + // never modeled, so the retained mapping vCard is the only lossless copy. + expect(seeded.localVCard).not.toContain('CATEGORIES'); + expect(seeded.localVCard).not.toContain('X-CUSTOM-FLAG'); + expect(seeded.localVCard).not.toContain('TZ:'); + + resetObservation(fixture); + const updated = await contactService.updateContact( + seeded.userId, + seeded.contact.id, + { displayName: 'Imported Renamed' }, + ); + expect(updated.display_name).toBe('Imported Renamed'); + + const putRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(putRequest).toBeDefined(); + // The user's edit landed on the remote resource... + expect(putRequest.body).toContain('FN:Imported Renamed'); + // ...and the unmodeled remote properties survived the round-trip. + expect(putRequest.body).toContain('CATEGORIES:Friends,VIP'); + expect(putRequest.body).toContain('X-CUSTOM-FLAG:keep-me'); + expect(putRequest.body).toContain('TZ:America/New_York'); + + const after = await authoritativeState(seeded.userId); + expect(after.conflicts).toEqual([]); + expect(await pendingIntentState(seeded.userId)).toEqual([]); + // The confirmed local + mapping vCards now both retain the properties too. + expect(after.contacts[0].vcard).toContain('CATEGORIES:Friends,VIP'); + expect(after.remoteObjects[0].vcard).toContain('X-CUSTOM-FLAG:keep-me'); + expectNoNetworkInsideTransactions(); + } finally { + await fixture.close(); + } + }, 120_000); + + it('merges a native client replace: modeled full-state, unmodeled name-level, remote UID kept', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const remoteUid = 'remote-server-uid-777'; + const remoteVCard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${remoteUid}`, + 'FN:Native Person', + 'EMAIL:native@example.test', + 'ORG:Remote Org', + 'CATEGORIES:Colleagues,Board', + 'X-SURVIVE-ME:keep-this', + 'TZ:Europe/Berlin', + 'END:VCARD', + '', + ].join('\r\n'); + const seeded = await seedImportedContact(fixture, remoteVCard); + + try { + // A native client renamed the contact, CHANGED an unmodeled property (CATEGORIES), + // ADDED one (X-CLIENT-NOTE), round-tripped TZ, and — as a stripping client would — + // dropped an unmodeled property (X-SURVIVE-ME) AND a modeled one (ORG). + const clientBody = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${seeded.uid}`, + 'FN:Native Renamed', + 'EMAIL:native@example.test', + 'CATEGORIES:Colleagues,VIP', + 'TZ:Europe/Berlin', + 'X-CLIENT-NOTE:added-by-client', + 'END:VCARD', + '', + ].join('\r\n'); + + resetObservation(fixture); + const replaced = await contactService.replaceContactFromVCard(seeded.userId, { + localAddressBookId: seeded.localBookId, + uid: seeded.uid, + rawVCard: clientBody, + expectedLocalEtag: seeded.contact.etag, + }); + expect(replaced.display_name).toBe('Native Renamed'); + + const putRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(putRequest).toBeDefined(); + // Unmodeled: names present in the client body win (edit + add reach the remote)... + expect(putRequest.body).toContain('FN:Native Renamed'); + expect(putRequest.body).toContain('CATEGORIES:Colleagues,VIP'); + expect(putRequest.body).toContain('X-CLIENT-NOTE:added-by-client'); + expect(putRequest.body).toContain('TZ:Europe/Berlin'); + // ...and a name the stripping client OMITTED survives from the retained document. + expect(putRequest.body).toContain('X-SURVIVE-ME:keep-this'); + // Modeled: full-state from the client, so the omitted ORG is removed. + expect(putRequest.body).not.toContain('ORG:'); + // The outgoing document keeps the remote-owned UID, never the local key. + expect(contactFromVCardDocument(parseVCardDocument(putRequest.body)).uid).toBe(remoteUid); + + const after = await authoritativeState(seeded.userId); + expect(after.conflicts).toEqual([]); + expect(await pendingIntentState(seeded.userId)).toEqual([]); + expect(contactFromVCardDocument(parseVCardDocument(after.remoteObjects[0].vcard)).uid) + .toBe(remoteUid); + expect(after.remoteObjects[0].vcard).toContain('X-SURVIVE-ME:keep-this'); + expect(after.contacts[0].uid).toBe(seeded.uid); + expectNoNetworkInsideTransactions(); + } finally { + await fixture.close(); + } + }, 120_000); + + // Seed a mapped contact whose retained document has `retainedProps`, PUT a client body + // whose extra lines are `clientProps` (FN/EMAIL held constant so only the merge varies), + // and return the outgoing PUT document. + async function mappedReplacePut(fixture, retainedProps, clientProps) { + const remoteVCard = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:merge-remote-uid', 'FN:Merge Person', + 'EMAIL:merge@example.test', ...retainedProps, 'END:VCARD', '', + ].join('\r\n'); + const seeded = await seedImportedContact(fixture, remoteVCard); + const clientBody = [ + 'BEGIN:VCARD', 'VERSION:3.0', `UID:${seeded.uid}`, 'FN:Merge Person', + 'EMAIL:merge@example.test', ...clientProps, 'END:VCARD', '', + ].join('\r\n'); + resetObservation(fixture); + await contactService.replaceContactFromVCard(seeded.userId, { + localAddressBookId: seeded.localBookId, uid: seeded.uid, + rawVCard: clientBody, expectedLocalEtag: seeded.contact.etag, + }); + return parseVCardDocument(fixture.requests.find(request => request.method === 'PUT').body); + } + + it('merges repeated unmodeled instances all-or-nothing per property name', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + // Client submits ONE X-CUSTOM-TAG → exactly the client's one on the wire (its full + // instance set replaces the retained set for that name). + const replaced = await mappedReplacePut( + fixture, + ['X-CUSTOM-TAG:one', 'X-CUSTOM-TAG:two', 'X-CUSTOM-TAG:three'], + ['X-CUSTOM-TAG:only'], + ); + const tags = replaced.properties.filter(p => p.name === 'X-CUSTOM-TAG').map(p => p.rawValue); + expect(tags).toEqual(['only']); + } finally { + await fixture.close(); + } + }, 120_000); + + it('keeps all retained instances of a name the client omits', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const replaced = await mappedReplacePut( + fixture, + ['X-CUSTOM-TAG:one', 'X-CUSTOM-TAG:two', 'X-CUSTOM-TAG:three'], + [], + ); + const tags = replaced.properties.filter(p => p.name === 'X-CUSTOM-TAG').map(p => p.rawValue); + expect(tags).toEqual(['one', 'two', 'three']); + } finally { + await fixture.close(); + } + }, 120_000); + + it('survives a grouped unmodeled property with its whole item group, including X-ABLABEL', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + // Client (stripping) omits the whole grouped unmodeled property. + const replaced = await mappedReplacePut( + fixture, + ['item1.X-CUSTOM-FIELD:custom-value', 'item1.X-ABLABEL:Custom Label'], + [], + ); + const field = replaced.properties.find(p => p.name === 'X-CUSTOM-FIELD'); + const label = replaced.properties.find(p => p.name === 'X-ABLABEL'); + expect(field?.rawValue).toBe('custom-value'); + // The whole group survives atomically — the label is not orphaned away. + expect(label?.rawValue).toBe('Custom Label'); + expect(field.group.toLowerCase()).toBe(label.group.toLowerCase()); + } finally { + await fixture.close(); + } + }, 120_000); + + it('re-prefixes a surviving group that collides with a group the client body reuses', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + // Retained item1 is an unmodeled grouped survivor the client omits; the client reuses + // item1 for its own modeled Additional field (URL). The survivor must move off item1. + const replaced = await mappedReplacePut( + fixture, + ['item1.X-CUSTOM-FIELD:custom-value', 'item1.X-ABLABEL:Custom Label'], + ['item1.URL:https://client.example.test/', 'item1.X-ABLABEL:Client Link'], + ); + const field = replaced.properties.find(p => p.name === 'X-CUSTOM-FIELD'); + const url = replaced.properties.find(p => p.name === 'URL'); + expect(field?.rawValue).toBe('custom-value'); + expect(url?.rawValue).toContain('client.example.test'); + // The client keeps item1; the surviving group is re-prefixed to a different group. + expect(url.group.toLowerCase()).toBe('item1'); + expect(field.group.toLowerCase()).not.toBe('item1'); + // ...and it carries its own label under the new prefix. + const survivorLabel = replaced.properties.find( + p => p.name === 'X-ABLABEL' && p.group?.toLowerCase() === field.group.toLowerCase()); + expect(survivorLabel?.rawValue).toBe('Custom Label'); + } finally { + await fixture.close(); + } + }, 120_000); + + it('does not duplicate the modeled main when a mixed Apple item-group survives (W7)', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + // Standard Apple layout: a MODELED main (ADR) grouped with an unmodeled sibling + // (X-ABADR) and its label. The client edits the ADR and, as a stripping client, + // omits the X-ABADR it does not understand. + const replaced = await mappedReplacePut( + fixture, + ['item1.ADR:;;123 Old St;;;;', 'item1.X-ABADR:us', 'item1.X-ABLABEL:Home'], + ['item1.ADR:;;456 New Ave;;;;', 'item1.X-ABLABEL:Home'], + ); + // Exactly ONE ADR on the wire — the client's; the retained modeled main is NOT + // re-emitted alongside it (no duplicate address). + const adrs = replaced.properties.filter(p => p.name === 'ADR'); + expect(adrs).toHaveLength(1); + expect(adrs[0].rawValue).toContain('456 New Ave'); + // The unmodeled annotation and its label still survive (accepted: possibly stale). + const abadr = replaced.properties.find(p => p.name === 'X-ABADR'); + expect(abadr?.rawValue).toBe('us'); + const survivorLabel = replaced.properties.find( + p => p.name === 'X-ABLABEL' && p.group?.toLowerCase() === abadr.group?.toLowerCase()); + expect(survivorLabel?.rawValue).toBe('Home'); + } finally { + await fixture.close(); + } + }, 120_000); + + function listContacts(userId, query) { + const handler = contactsRouter.stack + .find(layer => layer.route?.path === '/' && layer.route.methods.get) + .route.stack.at(-1).handle; + return new Promise((resolve, reject) => { + handler( + { query, session: { userId } }, + { + json: resolve, + status: () => ({ json: body => reject(new Error(JSON.stringify(body))) }), + }, + ).catch(reject); + }); + } + + // Contacts that all tie on the list's sort key (no name, no email, same is_auto and + // send_count) — the tie is what an unstable sort resolves differently per window. + async function seedTiedContacts(userId, size) { + const { rows: [book] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Paging') RETURNING id", + [userId], + ); + await databaseClient.query(` + INSERT INTO contacts (address_book_id, user_id, uid, vcard, etag, is_auto, send_count) + SELECT $1, $2, 'page-' || i, 'BEGIN:VCARD\r\nEND:VCARD', 'etag-' || i, false, 0 + FROM generate_series(1, $3) AS i + `, [book.id, userId, size]); + } + + // LIMIT/OFFSET over an ORDER BY with no unique tiebreaker leaves windows free to + // overlap, which drops rows from the union — a contact no amount of paging can reach. + it('pages contacts into disjoint windows that reach every contact', async () => { + const userId = await seedUser(); + // The production book's size: enough rows that PostgreSQL plans the deeper offsets + // differently from the first window, which is when an unstable sort reorders ties. + const size = 900; + const pageSize = 100; + await seedTiedContacts(userId, size); + + const windows = []; + for (let offset = 0; offset < size; offset += pageSize) { + windows.push(await listContacts(userId, { limit: pageSize, offset })); + } + + const ids = windows.flatMap(page => page.contacts.map(contact => contact.id)); + expect(windows.every(page => page.total === size)).toBe(true); + expect(windows.every(page => page.contacts.length === pageSize)).toBe(true); + expect(ids).toHaveLength(size); + expect(new Set(ids).size).toBe(size); + }, 120_000); + + it('returns a stable window for a repeated offset and stops past the end', async () => { + const userId = await seedUser(); + await seedTiedContacts(userId, 120); + + const [first, repeat, past] = await Promise.all([ + listContacts(userId, { limit: 50, offset: 50 }), + listContacts(userId, { limit: 50, offset: 50 }), + listContacts(userId, { limit: 50, offset: 120 }), + ]); + + expect(repeat.contacts.map(contact => contact.id)) + .toEqual(first.contacts.map(contact => contact.id)); + expect(past.contacts).toEqual([]); + expect(past.total).toBe(120); + }, 120_000); +}); diff --git a/backend/src/services/carddavContactService.js b/backend/src/services/carddavContactService.js new file mode 100644 index 00000000..9c07fcf0 --- /dev/null +++ b/backend/src/services/carddavContactService.js @@ -0,0 +1,1489 @@ +import { randomUUID } from 'node:crypto'; + +import { + ADDITIONAL_PROPERTIES, + SERVER_OWNED_PROPERTIES, + allocateItemGroup, + contactFromVCardDocument, + groupKey, + localContactHash, + localVCardEtag, + overlayContactOnVCard, + parseVCardDocument, + presentedVCard, + primaryEmail, + semanticVCardHash, + serializeVCardDocument, + withDocumentUid, +} from '../utils/vcardProperties.js'; + +// Property names (case-normalized) MailFlow projects into modeled columns / Additional +// fields. Everything else is an unmodeled property for the two-tier replace merge. +const MODELED_PROPERTY_NAMES = new Set([ + 'VERSION', 'UID', 'FN', 'N', 'EMAIL', 'TEL', 'ORG', 'NOTE', 'PHOTO', 'X-ABLABEL', + ...ADDITIONAL_PROPERTIES, +]); +import { + deleteCardResource, + discoverAddressBooks, + fetchCardResource, + putCardResource, +} from './carddavClient.js'; +import { + CardDavError, + activeRetryAfterAt, + resolveCarddavCredentials, +} from './carddavTransport.js'; +import { + applyConfirmedRemoteContact, + applyRemoteTombstone, + lockCarddavIntegration, + lockCarddavMapping, + persistDiscoveredBook, + persistDeniedBookCapability, + persistPendingMutationIntent, + refreshUnresolvedConflict, + rotateBookToken, + restorePendingMutationIntent, + typedError, +} from './carddavMappingState.js'; +import { query, withTransaction } from './db.js'; + +const API_CONTACT_COLUMNS = ` + id, uid, display_name, first_name, last_name, primary_email, + emails, phones, organization, notes, photo_data, additional_fields, + is_auto, send_count, last_sent, etag, created_at, updated_at`; +const DRAFT_FIELDS = [ + ['displayName', 'display_name'], + ['firstName', 'first_name'], + ['lastName', 'last_name'], + ['emails', 'emails'], + ['phones', 'phones'], + ['organization', 'organization'], + ['notes', 'notes'], + ['photoData', 'photo_data'], + ['additionalFields', 'additional_fields'], +]; + +export const CARDDAV_CONTACT_ERROR_STATUS = Object.freeze({ + ERR_CONTACT_VALIDATION: 400, + ERR_CONTACT_UID_MISMATCH: 400, + ERR_CONTACT_NOT_FOUND: 404, + ERR_ADDRESS_BOOK_NOT_FOUND: 404, + ERR_CONTACT_EXISTS: 409, + ERR_CARDDAV_CONFLICT: 409, + ERR_CARDDAV_READ_ONLY: 403, + ERR_CARDDAV_FINAL_FENCE: 503, + ERR_CARDDAV_STALE_GENERATION: 503, + ERR_CARDDAV_AMBIGUOUS_WRITE: 409, + ERR_CARDDAV_PENDING_INTENT: 409, + '23505': 409, +}); + +export class CardDavConflictError extends Error { + constructor(conflictId, options = {}) { + super('The CardDAV contact changed before this write completed', options); + this.name = 'CardDavConflictError'; + this.code = 'ERR_CARDDAV_CONFLICT'; + this.conflictId = conflictId; + } +} + +export class CardDavAmbiguousWriteError extends Error { + constructor(operation, details = {}, options = {}) { + super('The CardDAV write succeeded, but MailFlow could not confirm its local state', options); + this.name = 'CardDavAmbiguousWriteError'; + this.code = 'ERR_CARDDAV_AMBIGUOUS_WRITE'; + this.operation = operation; + Object.assign(this, details); + } +} + +function normalizedDocumentContact(document) { + const contact = contactFromVCardDocument(document); + return { ...contact, primaryEmail: primaryEmail(contact) }; +} + +function contactPayload(contact, document, vcard) { + return { + ...contact, + primaryEmail: primaryEmail(contact), + document, + vcard, + etag: localVCardEtag(vcard), + remoteSemanticHash: semanticVCardHash(document), + localContactHash: localContactHash(contact), + }; +} + +function draftWithCurrent(current, draft) { + const merged = { uid: current.uid }; + for (const [camel, snake] of DRAFT_FIELDS) { + merged[camel] = Object.hasOwn(draft || {}, camel) ? draft[camel] : current[snake]; + } + merged.emails ??= []; + merged.phones ??= []; + merged.additionalFields ??= []; + return merged; +} + +function validateContact(contact) { + if (!Array.isArray(contact.emails)) throw typedError('emails must be an array', 'ERR_CONTACT_VALIDATION'); + if (!Array.isArray(contact.phones)) throw typedError('phones must be an array', 'ERR_CONTACT_VALIDATION'); + if (!Array.isArray(contact.additionalFields)) { + throw typedError('additionalFields must be an array', 'ERR_CONTACT_VALIDATION'); + } + if (!contact.displayName && !primaryEmail(contact)) { + throw typedError('A name or email address is required', 'ERR_CONTACT_VALIDATION'); + } +} + +function normalizedCreateDraft(draft) { + const normalized = { + displayName: null, + firstName: null, + lastName: null, + emails: [], + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [], + ...(draft || {}), + }; + validateContact(normalized); + return normalized; +} + +function payloadForDraft(uid, draft, version = null, retainedDocument = null, options = {}) { + const contact = { uid, ...draft }; + validateContact(contact); + const document = retainedDocument + ? { ...retainedDocument, version: version ?? retainedDocument.version } + : { version: version ?? '3.0', properties: [] }; + const updatedDocument = overlayContactOnVCard(document, contact, options); + const vcard = serializeVCardDocument(updatedDocument); + return contactPayload(normalizedDocumentContact(updatedDocument), updatedDocument, vcard); +} + +function parseClientVCard(rawVCard) { + try { + return parseVCardDocument(rawVCard); + } catch (cause) { + // A malformed client body is a request error, not a server fault: give it a + // typed code so the route maps it to 400 instead of a generic 500. + throw typedError(cause.message || 'The vCard body could not be parsed', 'ERR_CONTACT_VALIDATION', { cause }); + } +} + +function payloadForRawVCard(uid, rawVCard) { + if (typeof uid !== 'string' || !uid) throw typedError('Contact UID is required', 'ERR_CONTACT_VALIDATION'); + if (typeof rawVCard !== 'string') throw typedError('rawVCard must be a string', 'ERR_CONTACT_VALIDATION'); + const document = parseClientVCard(rawVCard); + const contact = normalizedDocumentContact(document); + if (contact.uid !== uid) { + throw typedError('The vCard UID does not match the resource UID', 'ERR_CONTACT_UID_MISMATCH'); + } + validateContact(contact); + return contactPayload(contact, document, rawVCard); +} + +// Confirmed-remote projection after a mapped write. The remote resource owns the vCard +// UID, so the caller stores the retained remote document verbatim in the mapping +// while the LOCAL contact keeps its stable local key: the projection is re-keyed onto +// localUid, its vCard is re-serialized with the local UID (still lossless), and +// localContactHash reflects the local key. document/remoteSemanticHash stay on the +// remote UID for the mapping. For a push-origin/create contact localUid equals the +// remote UID and this is a no-op re-key (unlike payloadForRawVCard, no UID-match check). +export function confirmedRemotePayload(localUid, remoteVcard) { + const document = parseVCardDocument(remoteVcard); + const remoteContact = normalizedDocumentContact(document); + validateContact(remoteContact); + const localContact = { ...remoteContact, uid: localUid }; + const localVcard = serializeVCardDocument(overlayContactOnVCard(document, localContact)); + return contactPayload(localContact, document, localVcard); +} + +// A mapped CardDAV-server PUT is a two-tier merge (not a full replacement). +// - MODELED fields: the client body is authoritative full state — it received them all, +// so an omitted modeled field is a deliberate removal. +// - UNMODELED properties: a property-NAME-level merge — names the client body includes +// win (all of that name's instances, replacing the retained set), names it omits survive +// from the retained document, so a client that strips properties it does not understand +// cannot silently delete them. Survivorship is atomic PER GROUP: when a grouped +// unmodeled property survives, its whole itemN group (including the group's X-ABLABEL and +// grouped parameters) survives; if the client reuses that group prefix, the survivor is +// re-prefixed to a fresh itemN. +// Server-owned metadata (REV/PRODID/…) is never replayed. UID is re-keyed to the retained +// remote UID. +// DOCUMENTED LIMITATIONS: deleting an unmodeled property through a DAV client is unsupported +// (name-absent survives — not silent loss, just not a delete); and the per-name merge is +// all-or-nothing (a client submitting one instance of a repeated name replaces the whole +// retained instance set for that name — last-writer-wins per property name). +function mergedReplacePayload(clientDocument, retainedDocument) { + const clientNames = new Set(clientDocument.properties.map(property => property.name)); + const retainedUidProperty = retainedDocument.properties.find(property => property.name === 'UID'); + const withUid = withDocumentUid(clientDocument, retainedUidProperty); + const usedGroupKeys = new Set(withUid.properties.map(property => groupKey(property.group)).filter(Boolean)); + + // Bucket retained non-server-owned properties by group. + const retainedGroups = new Map(); + for (const property of retainedDocument.properties) { + if (SERVER_OWNED_PROPERTIES.has(property.name)) continue; + const key = groupKey(property.group); + if (!retainedGroups.has(key)) retainedGroups.set(key, []); + retainedGroups.get(key).push(property); + } + + const survivors = []; + for (const [key, groupProperties] of retainedGroups) { + if (key === '') { + // Ungrouped: each surviving unmodeled property (name the client omitted) stands alone. + for (const property of groupProperties) { + if (!MODELED_PROPERTY_NAMES.has(property.name) && !clientNames.has(property.name)) { + survivors.push(property); + } + } + continue; + } + // A grouped property survives iff the group has an unmodeled member whose name the client + // omitted. Then the group survives, but W7: emit ONLY its unmodeled members and its + // X-ABLABEL — NEVER a modeled main (ADR/URL/TEL/…) the client already owns via its + // full-state modeled fields, which would duplicate that property on the wire (the standard + // Apple item1.ADR + item1.X-ABADR + item1.X-ABLABEL layout). ACCEPTED CONSEQUENCE: a + // surviving unmodeled annotation may be stale relative to a client-edited modeled main — + // consistent with the can't-delete-unmodeled stance (keep it, do not silently lose it). + // Keep the group's unmodeled members the client omitted (present-name-wins) plus + // its X-ABLABEL (group-scoped, so it labels the survivor even when the client also uses + // one for a different group). Modeled mains are never re-emitted. + const kept = groupProperties.filter(property => ( + property.name === 'X-ABLABEL' + || (!MODELED_PROPERTY_NAMES.has(property.name) && !clientNames.has(property.name)) + )); + const survives = kept.some(property => property.name !== 'X-ABLABEL'); + if (!survives) continue; + let prefix; + if (usedGroupKeys.has(key)) { + prefix = allocateItemGroup(usedGroupKeys); // collision with a client group → re-prefix + } else { + usedGroupKeys.add(key); + prefix = groupProperties[0].group; + } + for (const property of kept) survivors.push({ ...property, group: prefix }); + } + + const properties = [...withUid.properties, ...survivors] + .filter(property => !SERVER_OWNED_PROPERTIES.has(property.name)); + const outgoing = { ...withUid, properties }; + return contactPayload(normalizedDocumentContact(outgoing), outgoing, serializeVCardDocument(outgoing)); +} + +// Edits to a synced contact overlay onto the retained lossless remote vCard so +// unmodeled properties (CATEGORIES, KEY, TZ, X-*, …) survive the pull→edit→PUT +// round-trip. The local contacts.vcard is a lossy re-serialization from the pull +// (generateVCard keeps only modeled properties); it only backs edits made before +// a CardDAV mapping exists. +function retainedEditDocument(contact) { + return parseVCardDocument(contact.mapping_vcard ?? contact.vcard); +} + +function mappingBookId(contact) { + return contact.mapping_address_book_id ?? (contact.href ? contact.address_book_id : null); +} + +function localBookId(contact) { + return contact.local_address_book_id ?? contact.address_book_id; +} + +function remoteBookUrl(contact) { + return contact.remote_book_url ?? contact.external_url; +} + +function capability(contact, operation) { + return contact[`remote_${operation}_capability`] ?? 'unknown'; +} + +function assertWritable(contact, operation) { + if (capability(contact, operation) === 'denied') { + throw typedError(`This CardDAV address book does not allow ${operation}`, 'ERR_CARDDAV_READ_ONLY'); + } + if (contact.mapping_status === 'conflict' && contact.conflict_id) { + throw new CardDavConflictError(contact.conflict_id); + } +} + +async function readIntegration(client, userId, lock = false) { + if (lock) return lockCarddavIntegration(client, userId, { requireServerUrl: true }); + const { rows: [integration] } = await client.query( + `SELECT id, config + FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + `, + [userId], + ); + return integration?.config?.serverUrl ? integration : null; +} + +async function readContact(client, userId, { contactId, localAddressBookId, uid }) { + const conditions = contactId + ? 'c.id = $2' + : 'c.address_book_id = $2 AND c.uid = $3'; + const params = contactId + ? [userId, contactId] + : [userId, localAddressBookId, uid]; + const { rows: [contact] } = await client.query( + `SELECT c.*, + c.address_book_id AS local_address_book_id, + mapping.address_book_id AS mapping_address_book_id, + mapping.href, mapping.remote_etag, mapping.mapping_status, + mapping.vcard AS mapping_vcard, + mapping.vcard_version, mapping.remote_semantic_hash, + mapping.local_contact_hash, mapping.mapping_revision, + mapping.pending_operation, mapping.pending_vcard, + mapping.pending_local_hash, mapping.pending_remote_semantic_hash, + mapping.pending_started_at, mapping.updated_at AS mapping_updated_at, + remote_book.external_url AS remote_book_url, + remote_book.remote_create_capability, + remote_book.remote_update_capability, + remote_book.remote_delete_capability, + conflict.id AS conflict_id + FROM contacts c + JOIN address_books local_book ON local_book.id = c.address_book_id + LEFT JOIN carddav_remote_objects mapping + ON mapping.local_contact_id = c.id + AND mapping.mapping_status <> 'pending_materialization' + LEFT JOIN address_books remote_book ON remote_book.id = mapping.address_book_id + LEFT JOIN carddav_conflicts conflict + ON conflict.address_book_id = mapping.address_book_id + AND conflict.href = mapping.href + AND conflict.status = 'unresolved' + WHERE c.user_id = $1 AND ${conditions}`, + params, + ); + return contact || null; +} + +async function readOwnedBook(client, userId, addressBookId) { + const { rows: [book] } = await client.query( + `SELECT id, source + FROM address_books + WHERE id = $1 AND user_id = $2`, + [addressBookId, userId], + ); + return book || null; +} + +async function credentials(integration) { + const config = integration.config; + const resolved = await resolveCarddavCredentials(config); + const { password } = resolved; + if (!password) throw typedError('Stored CardDAV credentials could not be read', 'ERR_CARDDAV_CREDENTIALS'); + return { + serverUrl: config.serverUrl, + ...resolved, + connectionGeneration: config.connectionGeneration ?? null, + }; +} + +async function discoverCreateContext(userId, integration) { + assertRetryEligible(integration, 'create'); + const creds = await credentials(integration); + try { + const books = await discoverAddressBooks({ + serverUrl: creds.serverUrl, + ...resourceCredentials(creds), + }); + return { books, creds }; + } catch (error) { + if (isThrottle(error)) { + await recordThrottle( + userId, + integration.config.connectionGeneration ?? null, + error, + ); + } + throw error; + } +} + +function selectedCreateBook(books) { + if (!Array.isArray(books)) throw typedError('A fresh CardDAV book snapshot is required', 'ERR_CARDDAV_BOOKS'); + const selected = books.find(book => book.capabilities?.create === 'allowed') + || books.find(book => (book.capabilities?.create ?? 'unknown') === 'unknown'); + if (!selected) throw typedError('No writable CardDAV address book was discovered', 'ERR_CARDDAV_READ_ONLY'); + return selected; +} + +function selectedVCardVersion(book) { + const advertised = (book.addressData || []).map(entry => entry.version); + return advertised.length > 0 && advertised.every(version => version === '4.0') ? '4.0' : '3.0'; +} + +function resourceCredentials(creds) { + return { + username: creds.username, + password: creds.password, + allowPrivate: creds.allowPrivate, + }; +} + +function safeLocation(url) { + const parsed = URL.parse(url); + return { origin: parsed?.origin ?? null, path: parsed?.pathname ?? null }; +} + +function logMutation({ operation, contactId, addressBookId, href, status, retryDecision, conflictTransition, startedAt }) { + console.info('[carddav-contact-mutation]', { + operation, + contactId: contactId ?? null, + addressBookId: addressBookId ?? null, + ...safeLocation(href), + status: status ?? null, + retryDecision: retryDecision ?? null, + conflictTransition: conflictTransition ?? null, + durationMs: Date.now() - startedAt, + }); +} + +async function ensureLocalBook(client, userId) { + const { rows: [book] } = await client.query( + `INSERT INTO address_books (user_id, name) + VALUES ($1, 'Personal') + ON CONFLICT (user_id, name) DO UPDATE SET updated_at = address_books.updated_at + RETURNING id`, + [userId], + ); + return book.id; +} + +export function contactValues(payload) { + return [ + payload.uid, + payload.vcard, + payload.etag, + payload.displayName, + payload.firstName, + payload.lastName, + payload.primaryEmail, + JSON.stringify(payload.emails || []), + JSON.stringify(payload.phones || []), + payload.organization, + payload.notes, + payload.photoData, + JSON.stringify(payload.additionalFields || []), + ]; +} + +export async function insertContact(client, userId, addressBookId, payload, { + returning = API_CONTACT_COLUMNS, +} = {}) { + const { rows: [row] } = await client.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, + display_name, first_name, last_name, primary_email, + emails, phones, organization, notes, photo_data, additional_fields, is_auto + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb,false) + RETURNING ${returning}`, + [addressBookId, userId, ...contactValues(payload)], + ); + return row; +} + +function isLocalContactUidConflict(error) { + return error?.code === '23505' + && error.constraint === 'contacts_address_book_id_uid_key'; +} + +export async function updateStoredContact( + client, + userId, + contactId, + payload, + expectedEtag = null, + { returning = API_CONTACT_COLUMNS, onMissing = null } = {}, +) { + const etagFence = expectedEtag == null ? '' : 'AND etag = $16'; + const params = [...contactValues(payload), contactId, userId]; + if (expectedEtag != null) params.push(expectedEtag); + const { rows: [row] } = await client.query( + `UPDATE contacts SET + uid = $1, vcard = $2, etag = $3, + display_name = $4, first_name = $5, last_name = $6, + primary_email = $7, emails = $8::jsonb, phones = $9::jsonb, + organization = $10, notes = $11, photo_data = $12, + additional_fields = $13::jsonb, is_auto = false, updated_at = NOW() + WHERE id = $14 AND user_id = $15 ${etagFence} + RETURNING ${returning}`, + params, + ); + if (!row) { + if (onMissing) throw onMissing(); + if (expectedEtag != null) { + throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH'); + } + throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + } + return row; +} + +async function bumpLocalBook(client, userId, addressBookId) { + await rotateBookToken(client, userId, addressBookId); +} + +async function createLocal(client, userId, localAddressBookId, payload, expectedAbsent = false) { + const addressBookId = localAddressBookId ?? await ensureLocalBook(client, userId); + let row; + try { + row = await insertContact(client, userId, addressBookId, payload); + } catch (error) { + if (expectedAbsent === true && isLocalContactUidConflict(error)) { + throw typedError('The contact already exists', 'ERR_LOCAL_PRECONDITION_FAILED'); + } + throw error; + } + await bumpLocalBook(client, userId, addressBookId); + return row; +} + +async function updateLocal(client, userId, contact, payload, expectedEtag = null) { + const row = await updateStoredContact( + client, + userId, + contact.id, + payload, + expectedEtag, + ); + await bumpLocalBook(client, userId, localBookId(contact)); + return row; +} + +async function deleteLocal(client, userId, contact, expectedEtag = null) { + const etagFence = expectedEtag == null ? '' : 'AND etag = $3'; + const params = [contact.id, userId]; + if (expectedEtag != null) params.push(expectedEtag); + const deleted = await client.query( + `DELETE FROM contacts + WHERE id = $1 AND user_id = $2 ${etagFence} + RETURNING address_book_id`, + params, + ); + if (deleted.rowCount !== 1) { + if (expectedEtag != null) { + throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH'); + } + throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + } + await bumpLocalBook(client, userId, localBookId(contact)); + return { ok: true }; +} + +async function serializable(callback) { + return withTransaction(async client => { + await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE'); + return callback(client); + }); +} + +async function confirmedCommit(operation, details, callback) { + try { + return await callback(); + } catch (cause) { + throw new CardDavAmbiguousWriteError(operation, details, { cause }); + } +} + +function sameGeneration(integration, generation) { + return (integration?.config?.connectionGeneration ?? null) === generation; +} + +function assertRetryEligible(integration, operation) { + const retryAfterAt = activeRetryAfterAt(integration?.config); + if (!retryAfterAt) return; + throw new CardDavError('CardDAV requests are throttled until Retry-After eligibility', { + status: 429, + operation, + retryAfterAt, + }); +} + +function isThrottle(error) { + return error instanceof CardDavError && error.status === 429; +} + +async function persistRetryAfter(client, integration, retryAfterAt) { + if (!retryAfterAt) return; + const result = await client.query( + `UPDATE user_integrations + SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text), true), + updated_at = NOW() + WHERE id = $1 + AND config->>'connectionGeneration' IS NOT DISTINCT FROM $3`, + [ + integration.id, + retryAfterAt, + integration.config.connectionGeneration ?? null, + ], + ); + if (result.rowCount !== 1) { + throw typedError('CardDAV connection changed after throttling', 'ERR_CARDDAV_FINAL_FENCE'); + } +} + +async function recordThrottle(userId, generation, error) { + if (!error.retryAfterAt) return; + await withTransaction(async client => { + const integration = await readIntegration(client, userId, true); + if (!sameGeneration(integration, generation)) { + throw typedError('CardDAV connection changed after throttling', 'ERR_CARDDAV_FINAL_FENCE'); + } + await persistRetryAfter(client, integration, error.retryAfterAt); + }); +} + +async function rollbackMappedThrottle(userId, preflight, error) { + await withTransaction(async client => { + const integration = await readIntegration(client, userId, true); + if (!sameGeneration(integration, preflight.connectionGeneration)) { + throw typedError('CardDAV connection changed after throttling', 'ERR_CARDDAV_FINAL_FENCE'); + } + assertMappingApplied(await restorePendingMutationIntent(client, { + addressBookId: preflight.addressBookId, + href: preflight.href, + expectedMappingRevision: preflight.mappingRevision, + operation: preflight.pendingOperation, + pendingVCard: preflight.pendingVCard, + pendingLocalHash: preflight.pendingLocalHash, + pendingRemoteSemanticHash: preflight.pendingRemoteSemanticHash, + pendingStartedAt: preflight.pendingStartedAt, + previousMappingStatus: preflight.previousMappingStatus, + previousMappingRevision: preflight.previousMappingRevision, + previousUpdatedAt: preflight.previousMappingUpdatedAt, + })); + await persistRetryAfter(client, integration, error.retryAfterAt); + }); +} + +async function lockMutationState(client, userId, preflight) { + const integration = await readIntegration(client, userId, true); + const mapping = await lockCarddavMapping(client, { + userId, + addressBookId: preflight.addressBookId, + href: preflight.href, + }); + return { integration, mapping }; +} + +function finalFenceMatches(state, preflight) { + return sameGeneration(state.integration, preflight.connectionGeneration) + && state.mapping + && String(state.mapping.mapping_revision) === String(preflight.mappingRevision); +} + +function assertFinalFence(state, preflight) { + if (!finalFenceMatches(state, preflight)) { + throw typedError('CardDAV mapping changed after the remote write', 'ERR_CARDDAV_FINAL_FENCE'); + } +} + +function assertMappingApplied(result) { + if (result.ok) return result; + throw typedError('CardDAV mapping changed after the remote write', 'ERR_CARDDAV_FINAL_FENCE'); +} + +function storedLocalContactHash(contact) { + return localContactHash(draftWithCurrent(contact, {})); +} + +async function commitRemoteCreate({ userId, preflight, localAddressBookId, book, remote }) { + return confirmedCommit('create', { + contactId: preflight.contactId ?? null, + href: remote.href, + }, async () => { + const payload = payloadForRawVCard(preflight.uid, remote.vcard); + return serializable(async client => { + const integration = await readIntegration(client, userId, true); + if (!sameGeneration(integration, preflight.connectionGeneration)) { + throw typedError('CardDAV connection changed after the remote write', 'ERR_CARDDAV_FINAL_FENCE'); + } + const localBook = localAddressBookId ?? await ensureLocalBook(client, userId); + const remoteBook = await persistDiscoveredBook(client, { userId, ...book }); + const row = preflight.contactId + ? await updateStoredContact( + client, + userId, + preflight.contactId, + payload, + preflight.localEtag, + ) + : await insertContact(client, userId, localBook, payload); + assertMappingApplied(await applyConfirmedRemoteContact(client, { + addressBookId: remoteBook.id, + href: remote.href, + expectedMappingRevision: null, + remoteEtag: remote.etag, + vcard: remote.vcard, + primaryEmail: payload.primaryEmail, + localContactId: row.id, + vcardVersion: payload.document.version, + remoteSemanticHash: payload.remoteSemanticHash, + localContactHash: payload.localContactHash, + })); + await bumpLocalBook(client, userId, localBook); + return row; + }); + }); +} + +function isNotFound(error) { + return error instanceof CardDavError && error.status === 404; +} + +export async function fetchCreated({ book, href, uid, creds }) { + try { + const remote = await fetchCardResource({ + url: book.url, + href, + ...resourceCredentials(creds), + }); + payloadForRawVCard(uid, remote.vcard); + return { kind: 'found', remote }; + } catch (cause) { + if (isNotFound(cause)) return { kind: 'missing' }; + return { kind: 'unknown', cause }; + } +} + +function ambiguousCreate(book, href, outcome) { + const cause = outcome.kind === 'unknown' + ? outcome.cause + : new CardDavError('Created CardDAV resource was not found', { + status: 404, + operation: 'fetch', + }); + return new CardDavAmbiguousWriteError('create', { + href: new URL(href, book.url).href, + }, { cause }); +} + +async function canonicalCreate({ book, uid, payload, creds }) { + const href = `${uid}.vcf`; + const options = { + url: book.url, + href, + vcard: payload.vcard, + ...resourceCredentials(creds), + }; + let firstPut = 'accepted'; + try { + await putCardResource(options); + } catch (error) { + if (error instanceof CardDavError && error.status != null) throw error; + firstPut = 'ambiguous'; + } + + switch (firstPut) { + case 'accepted': { + const final = await fetchCreated({ book, href, uid, creds }); + if (final.kind === 'found') { + return { remote: final.remote, retryDecision: 'not-retried' }; + } + throw ambiguousCreate(book, href, final); + } + case 'ambiguous': { + const recovery = await fetchCreated({ book, href, uid, creds }); + switch (recovery.kind) { + case 'found': + return { remote: recovery.remote, retryDecision: 'recovered-after-ambiguous' }; + case 'unknown': + throw ambiguousCreate(book, href, recovery); + case 'missing': + break; + } + + let retryDecision = 'retried-after-confirmed-missing'; + try { + await putCardResource(options); + } catch (retryError) { + if ( + retryError instanceof CardDavError + && retryError.status != null + && retryError.status !== 412 + ) { + throw retryError; + } + retryDecision = 'recovered-after-bounded-retry'; + } + const final = await fetchCreated({ book, href, uid, creds }); + switch (final.kind) { + case 'found': + return { remote: final.remote, retryDecision }; + case 'missing': + case 'unknown': + throw ambiguousCreate(book, href, final); + } + break; + } + } + throw new Error('Unreachable create outcome'); +} + +async function remoteCreate({ userId, preflight, localAddressBookId, book, payload, creds }) { + const activeCredentials = creds || await credentials(preflight.integration); + const startedAt = Date.now(); + try { + const { remote, retryDecision } = await canonicalCreate({ + book, + uid: preflight.uid, + payload, + creds: activeCredentials, + }); + const row = await commitRemoteCreate({ userId, preflight, localAddressBookId, book, remote }); + logMutation({ + operation: 'create', contactId: row.id, addressBookId: null, + href: remote.href, status: 200, retryDecision, startedAt, + }); + return row; + } catch (error) { + if (isThrottle(error)) { + await recordThrottle(userId, preflight.connectionGeneration, error); + } + if ( + preflight.expectedAbsent === true + && error instanceof CardDavError + && error.status === 412 + ) { + logMutation({ + operation: 'create', contactId: preflight.contactId, addressBookId: null, + href: book.url, status: 412, retryDecision: 'not-retried', startedAt, + }); + throw typedError('The contact already exists', 'ERR_LOCAL_PRECONDITION_FAILED'); + } + if (error instanceof CardDavError && (error.status === 403 || error.status === 405)) { + await recordDeniedCreate(userId, preflight.connectionGeneration, book); + } + logMutation({ + operation: 'create', contactId: preflight.contactId, addressBookId: null, + href: book.url, status: error.status, retryDecision: 'not-retried', startedAt, + }); + throw error; + } +} + +async function recordDeniedCreate(userId, generation, book) { + await withTransaction(async client => { + const integration = await readIntegration(client, userId, true); + if (!sameGeneration(integration, generation)) return; + const storedBook = await persistDiscoveredBook(client, { + userId, + ...book, + preserveCapabilities: true, + }); + await persistDeniedBookCapability(client, { + userId, + addressBookId: storedBook.id, + capability: 'create', + }); + }); +} + +async function recordDeniedMapped(userId, preflight, operation) { + await withTransaction(async client => { + const state = await lockMutationState(client, userId, preflight); + if (!finalFenceMatches(state, preflight)) return; + await persistDeniedBookCapability(client, { + userId, + addressBookId: preflight.addressBookId, + capability: operation, + }); + }); +} + +async function latestRemote(preflight, creds) { + try { + const remote = await fetchCardResource({ + url: preflight.url, + href: preflight.href, + ...resourceCredentials(creds), + }); + return { ...remote, tombstone: false }; + } catch (error) { + if (isNotFound(error)) { + return { href: preflight.href, etag: null, vcard: null, tombstone: true }; + } + throw error; + } +} + +async function commitPendingRecovery({ userId, preflight, remote }) { + let conflict; + const value = await serializable(async client => { + const state = await lockMutationState(client, userId, preflight); + assertFinalFence(state, preflight); + const intent = state.mapping; + if (!intent.pending_operation) { + throw typedError('The pending CardDAV intent is missing', 'ERR_CARDDAV_FINAL_FENCE'); + } + const contact = await readContact(client, userId, { contactId: preflight.contactId }); + const currentLocalHash = contact ? storedLocalContactHash(contact) : null; + const localMatches = currentLocalHash === intent.pending_local_hash; + const remotePayload = remote.payload ?? null; + const remoteMatches = intent.pending_operation === 'delete' + ? remote.tombstone + : !remote.tombstone + && remotePayload.remoteSemanticHash === intent.pending_remote_semantic_hash; + + if (remoteMatches && localMatches) { + if (intent.pending_operation === 'delete') { + assertMappingApplied(await applyRemoteTombstone(client, { + addressBookId: preflight.addressBookId, + href: preflight.href, + expectedMappingRevision: preflight.mappingRevision, + })); + const deleted = await client.query( + 'DELETE FROM contacts WHERE id = $1 AND user_id = $2', + [preflight.contactId, userId], + ); + if (deleted.rowCount !== 1) { + throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + } + await bumpLocalBook(client, userId, preflight.localAddressBookId); + return { ok: true }; + } + const row = await updateStoredContact( + client, + userId, + preflight.contactId, + remotePayload, + ); + assertMappingApplied(await applyConfirmedRemoteContact(client, { + addressBookId: preflight.addressBookId, + href: preflight.href, + expectedMappingRevision: preflight.mappingRevision, + remoteEtag: remote.etag, + vcard: remote.vcard, + primaryEmail: remotePayload.primaryEmail, + localContactId: preflight.contactId, + vcardVersion: remotePayload.document.version, + remoteSemanticHash: remotePayload.remoteSemanticHash, + localContactHash: remotePayload.localContactHash, + })); + await bumpLocalBook(client, userId, preflight.localAddressBookId); + return row; + } + + const preserveCurrentLocal = !localMatches && contact; + // A later keep-mailflow resolution pushes this snapshot verbatim, so preserve the + // current local contact losslessly onto the retained remote vCard — and keep the + // retained REMOTE UID (preserveDocumentUid), never the local key, so + // resolution can't rewrite the remote resource's UID. The pending-intent branch is + // already the overlaid payload vCard (which already carries the remote UID). + const localVCard = preserveCurrentLocal + ? presentedVCard(contact, { preserveDocumentUid: true }) + : intent.pending_vcard; + const result = await refreshUnresolvedConflict(client, { + addressBookId: preflight.addressBookId, + href: preflight.href, + expectedMappingRevision: preflight.mappingRevision, + userId, + baseLocalHash: intent.local_contact_hash, + remoteEtag: remote.etag, + localVCard, + remoteVCard: remote.vcard, + localTombstone: preserveCurrentLocal ? false : intent.pending_operation === 'delete', + remoteTombstone: remote.tombstone, + }); + assertMappingApplied(result); + conflict = result.conflict; + return null; + }); + if (conflict) throw new CardDavConflictError(conflict.id); + return value; +} + +async function recoverMappedIntent(userId, preflight, activeCredentials) { + const creds = activeCredentials || await credentials(preflight.integration); + let remote; + try { + remote = await latestRemote(preflight, creds); + } catch (cause) { + if (isThrottle(cause)) { + await recordThrottle(userId, preflight.connectionGeneration, cause); + } + throw new CardDavAmbiguousWriteError(preflight.pendingOperation, { + contactId: preflight.contactId, + href: preflight.href, + }, { cause }); + } + if (!remote.tombstone) { + remote = { ...remote, payload: confirmedRemotePayload(preflight.uid, remote.vcard) }; + } + try { + return await commitPendingRecovery({ userId, preflight, remote }); + } catch (error) { + if (error instanceof CardDavConflictError) throw error; + throw new CardDavAmbiguousWriteError(preflight.pendingOperation, { + contactId: preflight.contactId, + href: preflight.href, + }, { cause: error }); + } +} + +async function mappedUpdate(userId, preflight, payload) { + const creds = await credentials(preflight.integration); + const startedAt = Date.now(); + let writeError; + if (!preflight.recoveryOnly) { + try { + await putCardResource({ + url: preflight.url, + href: preflight.href, + etag: preflight.remoteEtag, + vcard: payload.vcard, + ...resourceCredentials(creds), + }); + } catch (error) { + if (isThrottle(error)) { + await rollbackMappedThrottle(userId, preflight, error); + logMutation({ + operation: preflight.pendingOperation, + contactId: preflight.contactId, + addressBookId: preflight.addressBookId, + href: preflight.href, + status: error.status, + retryDecision: 'scheduled-after-throttle', + startedAt, + }); + throw error; + } + writeError = error; + if (error instanceof CardDavError && (error.status === 403 || error.status === 405)) { + await recordDeniedMapped(userId, preflight, 'update'); + } + } + } + try { + const row = await recoverMappedIntent(userId, preflight, creds); + logMutation({ + operation: preflight.pendingOperation, + contactId: preflight.contactId, + addressBookId: preflight.addressBookId, + href: preflight.href, + status: writeError?.status ?? 200, + retryDecision: preflight.recoveryOnly ? 'read-only-recovery' : 'not-retried', + startedAt, + }); + return row; + } catch (error) { + logMutation({ + operation: preflight.pendingOperation, + contactId: preflight.contactId, + addressBookId: preflight.addressBookId, + href: preflight.href, + status: writeError?.status, + retryDecision: 'not-retried', + conflictTransition: error instanceof CardDavConflictError + ? 'created-or-refreshed' + : null, + startedAt, + }); + throw error; + } +} + +async function mappedDelete(userId, preflight) { + const creds = await credentials(preflight.integration); + const startedAt = Date.now(); + let writeError; + if (!preflight.recoveryOnly) { + try { + await deleteCardResource({ + url: preflight.url, + href: preflight.href, + etag: preflight.remoteEtag, + ...resourceCredentials(creds), + }); + } catch (error) { + if (isThrottle(error)) { + await rollbackMappedThrottle(userId, preflight, error); + logMutation({ + operation: preflight.pendingOperation, + contactId: preflight.contactId, + addressBookId: preflight.addressBookId, + href: preflight.href, + status: error.status, + retryDecision: 'scheduled-after-throttle', + startedAt, + }); + throw error; + } + writeError = error; + if (error instanceof CardDavError && (error.status === 403 || error.status === 405)) { + await recordDeniedMapped(userId, preflight, 'delete'); + } + } + } + try { + const result = await recoverMappedIntent(userId, preflight, creds); + logMutation({ + operation: preflight.pendingOperation, + contactId: preflight.contactId, + addressBookId: preflight.addressBookId, + href: preflight.href, + status: writeError?.status ?? 204, + retryDecision: preflight.recoveryOnly ? 'read-only-recovery' : 'not-retried', + startedAt, + }); + return result; + } catch (error) { + logMutation({ + operation: preflight.pendingOperation, + contactId: preflight.contactId, + addressBookId: preflight.addressBookId, + href: preflight.href, + status: writeError?.status, + retryDecision: 'not-retried', + conflictTransition: error instanceof CardDavConflictError + ? 'created-or-refreshed' + : null, + startedAt, + }); + throw error; + } +} + +function mappedPreflight(integration, contact) { + return { + integration, + connectionGeneration: integration.config.connectionGeneration ?? null, + contactId: contact.id, + uid: contact.uid, + localAddressBookId: localBookId(contact), + addressBookId: mappingBookId(contact), + url: remoteBookUrl(contact), + href: contact.href, + remoteEtag: contact.remote_etag, + mappingRevision: contact.mapping_revision, + pendingOperation: contact.pending_operation ?? null, + pendingVCard: contact.pending_vcard ?? null, + pendingLocalHash: contact.pending_local_hash ?? null, + pendingRemoteSemanticHash: contact.pending_remote_semantic_hash ?? null, + pendingStartedAt: contact.pending_started_at ?? null, + mappingStatus: contact.mapping_status, + mappingUpdatedAt: contact.mapping_updated_at, + }; +} + +async function prepareMappedMutation(client, userId, integration, contact, operation, payload = null) { + const preflight = mappedPreflight(integration, contact); + if (preflight.pendingOperation) { + const sameIntent = preflight.pendingOperation === operation + && (operation === 'delete' + || payload?.remoteSemanticHash === preflight.pendingRemoteSemanticHash); + if (!sameIntent) { + throw typedError( + 'A CardDAV mutation is already awaiting confirmation', + 'ERR_CARDDAV_PENDING_INTENT', + { operation: preflight.pendingOperation }, + ); + } + return { ...preflight, recoveryOnly: true }; + } + const pendingLocalHash = storedLocalContactHash(contact); + const applied = await persistPendingMutationIntent(client, { + userId, + addressBookId: preflight.addressBookId, + href: preflight.href, + expectedMappingRevision: preflight.mappingRevision, + operation, + pendingVCard: payload?.vcard ?? null, + pendingLocalHash, + pendingRemoteSemanticHash: payload?.remoteSemanticHash ?? null, + }); + assertMappingApplied(applied); + return { + ...preflight, + previousMappingRevision: preflight.mappingRevision, + previousMappingStatus: preflight.mappingStatus, + previousMappingUpdatedAt: preflight.mappingUpdatedAt, + mappingRevision: applied.mappingRevision, + pendingOperation: operation, + pendingVCard: payload?.vcard ?? null, + pendingLocalHash, + pendingRemoteSemanticHash: payload?.remoteSemanticHash ?? null, + pendingStartedAt: applied.pendingStartedAt, + payload, + }; +} + +async function supportsPendingIntentSchema() { + const { rows: [schema] } = await query( + `SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'carddav_remote_objects' + AND column_name = 'pending_operation' + ) AS supports_pending_intent`, + ); + return schema?.supports_pending_intent === true; +} + +export async function recoverPendingCarddavMutations(userId, { integration, creds } = {}) { + if (!integration?.config?.serverUrl) { + throw typedError('CardDAV is not connected', 'ERR_CARDDAV_NOT_CONNECTED'); + } + assertRetryEligible(integration, 'sync'); + if (!await supportsPendingIntentSchema()) return []; + const { rows } = await query( + `SELECT c.*, + c.address_book_id AS local_address_book_id, + mapping.address_book_id AS mapping_address_book_id, + mapping.href, mapping.remote_etag, mapping.mapping_status, + mapping.vcard_version, mapping.remote_semantic_hash, + mapping.local_contact_hash, mapping.mapping_revision::text, + mapping.pending_operation, mapping.pending_vcard, + mapping.pending_local_hash, mapping.pending_remote_semantic_hash, + mapping.pending_started_at, mapping.updated_at AS mapping_updated_at, + remote_book.external_url AS remote_book_url + FROM carddav_remote_objects mapping + JOIN contacts c ON c.id = mapping.local_contact_id + JOIN address_books remote_book ON remote_book.id = mapping.address_book_id + WHERE c.user_id = $1 AND mapping.pending_operation IS NOT NULL + ORDER BY mapping.address_book_id, mapping.href`, + [userId], + ); + const recovered = []; + for (const contact of rows) { + const preflight = { ...mappedPreflight(integration, contact), recoveryOnly: true }; + try { + recovered.push(await recoverMappedIntent(userId, preflight, creds)); + } catch (error) { + if (!(error instanceof CardDavConflictError)) throw error; + recovered.push({ conflictId: error.conflictId }); + } + } + return recovered; +} + +export async function createContact(userId, draft) { + const validatedDraft = normalizedCreateDraft(draft); + const uid = randomUUID(); + const preflight = await withTransaction(async client => ({ + integration: await readIntegration(client, userId), + uid, + })); + if (!preflight.integration) { + const payload = payloadForDraft(uid, validatedDraft); + return withTransaction(client => createLocal(client, userId, null, payload)); + } + const { books, creds } = await discoverCreateContext(userId, preflight.integration); + const selected = selectedCreateBook(books); + const payload = payloadForDraft(uid, validatedDraft, selectedVCardVersion(selected)); + return remoteCreate({ + userId, + preflight: { + ...preflight, + connectionGeneration: preflight.integration.config.connectionGeneration ?? null, + }, + localAddressBookId: null, + book: selected, + payload, + creds, + }); +} + +export async function updateContact(userId, contactId, draft) { + let localResult; + const prepared = await withTransaction(async client => { + const integration = await readIntegration(client, userId); + if (integration) assertRetryEligible(integration, 'update'); + const contact = await readContact(client, userId, { contactId }); + if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + if (!integration || !contact.href) { + const payload = payloadForDraft( + contact.uid, + draftWithCurrent(contact, draft), + undefined, + parseVCardDocument(contact.vcard), + ); + localResult = await updateLocal(client, userId, contact, payload); + return null; + } + assertWritable(contact, 'update'); + // The outgoing document preserves the retained remote UID: UID is + // remote-owned identity, so a Mailflow edit never rewrites it on the server. + const payload = payloadForDraft( + contact.uid, + draftWithCurrent(contact, draft), + undefined, + retainedEditDocument(contact), + { preserveDocumentUid: true }, + ); + return prepareMappedMutation(client, userId, integration, contact, 'update', payload); + }); + return prepared ? mappedUpdate(userId, prepared, prepared.payload) : localResult; +} + +export async function deleteContact(userId, contactId) { + let localResult; + const prepared = await withTransaction(async client => { + const integration = await readIntegration(client, userId); + if (integration) assertRetryEligible(integration, 'delete'); + const contact = await readContact(client, userId, { contactId }); + if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + if (!integration || !contact.href) { + localResult = await deleteLocal(client, userId, contact); + return null; + } + assertWritable(contact, 'delete'); + return prepareMappedMutation(client, userId, integration, contact, 'delete'); + }); + return prepared ? mappedDelete(userId, prepared) : localResult; +} + +export async function exportExistingContact(userId, contactId, { books, expectedGeneration }) { + const selected = selectedCreateBook(books); + const prepared = await withTransaction(async client => { + const integration = await readIntegration(client, userId); + if (!integration) throw typedError('CardDAV is not connected', 'ERR_CARDDAV_NOT_CONNECTED'); + assertRetryEligible(integration, 'create'); + const actualGeneration = integration.config.connectionGeneration ?? null; + if (expectedGeneration !== undefined && actualGeneration !== expectedGeneration) { + throw typedError( + 'The CardDAV connection changed before export', + 'ERR_CARDDAV_STALE_GENERATION', + { + expectedConnectionGeneration: expectedGeneration, + actualConnectionGeneration: actualGeneration, + }, + ); + } + const contact = await readContact(client, userId, { contactId }); + if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + if (contact.href) throw typedError('Contact already has a CardDAV mapping', 'ERR_CARDDAV_ALREADY_MAPPED'); + return { + integration, + connectionGeneration: integration.config.connectionGeneration ?? null, + uid: contact.uid, + contactId: contact.id, + localAddressBookId: localBookId(contact), + localEtag: contact.etag, + payload: payloadForDraft( + contact.uid, + draftWithCurrent(contact, {}), + selectedVCardVersion(selected), + parseVCardDocument(contact.vcard), + ), + }; + }); + return remoteCreate({ + userId, + preflight: prepared, + localAddressBookId: prepared.localAddressBookId, + book: selected, + payload: prepared.payload, + }); +} + +export async function createContactFromVCard(userId, { + localAddressBookId, + uid, + rawVCard, + expectedAbsent, +}) { + const payload = payloadForRawVCard(uid, rawVCard); + const requiresAbsent = expectedAbsent === true; + const prepared = await withTransaction(async client => { + const book = await readOwnedBook(client, userId, localAddressBookId); + if (!book) throw typedError('Address book not found', 'ERR_ADDRESS_BOOK_NOT_FOUND'); + const existing = await readContact(client, userId, { localAddressBookId, uid }); + if (existing && requiresAbsent) { + throw typedError('The contact already exists', 'ERR_LOCAL_PRECONDITION_FAILED'); + } + if (existing) throw typedError('Contact already exists', 'ERR_CONTACT_EXISTS'); + const integration = await readIntegration(client, userId); + if (!integration) { + return { + local: await createLocal( + client, + userId, + localAddressBookId, + payload, + requiresAbsent, + ), + }; + } + return { + integration, + connectionGeneration: integration.config.connectionGeneration ?? null, + uid, + expectedAbsent: requiresAbsent, + }; + }); + if (prepared.local) return prepared.local; + const { books, creds } = await discoverCreateContext(userId, prepared.integration); + const selected = selectedCreateBook(books); + return remoteCreate({ + userId, + preflight: prepared, + localAddressBookId, + book: selected, + payload, + creds, + }); +} + +export async function replaceContactFromVCard(userId, { + localAddressBookId, + uid, + rawVCard, + expectedLocalEtag, +}) { + const clientPayload = payloadForRawVCard(uid, rawVCard); + let localResult; + const prepared = await withTransaction(async client => { + const integration = await readIntegration(client, userId); + if (integration) assertRetryEligible(integration, 'update'); + const contact = await readContact(client, userId, { localAddressBookId, uid }); + if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + if (contact.etag !== expectedLocalEtag) { + throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH'); + } + if (!integration || !contact.href) { + // Unmapped/local-only: the client's document is the complete resource. + localResult = await updateLocal(client, userId, contact, clientPayload, expectedLocalEtag); + return null; + } + assertWritable(contact, 'update'); + // Mapped: two-tier merge. Modeled fields are full-state from the client; + // unmodeled properties merge by name (present-wins, absent survives); the remote UID + // is preserved. The route requires a conditional (If-Match) request for this path. + const payload = mergedReplacePayload(clientPayload.document, retainedEditDocument(contact)); + return prepareMappedMutation(client, userId, integration, contact, 'update', payload); + }); + return prepared ? mappedUpdate(userId, prepared, prepared.payload) : localResult; +} + +export async function deleteContactFromVCard(userId, { + localAddressBookId, + uid, + expectedLocalEtag, +}) { + let localResult; + const prepared = await withTransaction(async client => { + const integration = await readIntegration(client, userId); + if (integration) assertRetryEligible(integration, 'delete'); + const contact = await readContact(client, userId, { localAddressBookId, uid }); + if (!contact) throw typedError('Contact not found', 'ERR_CONTACT_NOT_FOUND'); + if (contact.etag !== expectedLocalEtag) { + throw typedError('The local contact changed', 'ERR_LOCAL_ETAG_MISMATCH'); + } + if (!integration || !contact.href) { + localResult = await deleteLocal(client, userId, contact, expectedLocalEtag); + return null; + } + assertWritable(contact, 'delete'); + return prepareMappedMutation(client, userId, integration, contact, 'delete'); + }); + return prepared ? mappedDelete(userId, prepared) : localResult; +} diff --git a/backend/src/services/carddavContactService.test.js b/backend/src/services/carddavContactService.test.js new file mode 100644 index 00000000..50bce3a1 --- /dev/null +++ b/backend/src/services/carddavContactService.test.js @@ -0,0 +1,1371 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + localContactHash, + parseVCardDocument, + semanticVCardHash, +} from '../utils/vcardProperties.js'; + +const runtime = vi.hoisted(() => ({ inTransaction: false, events: [] })); +const mocks = vi.hoisted(() => ({ + decrypt: vi.fn(value => value), + deleteCardResource: vi.fn(), + discoverAddressBooks: vi.fn(), + fetchCardResource: vi.fn(), + getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })), + putCardResource: vi.fn(), + query: vi.fn(), + randomUUID: vi.fn(), + withTransaction: vi.fn(), +})); + +vi.mock('node:crypto', async importOriginal => ({ + ...await importOriginal(), + randomUUID: mocks.randomUUID, +})); +vi.mock('./db.js', () => ({ query: mocks.query, withTransaction: mocks.withTransaction })); +vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt })); +vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy })); +vi.mock('./carddavClient.js', async importOriginal => ({ + ...await importOriginal(), + deleteCardResource: mocks.deleteCardResource, + discoverAddressBooks: mocks.discoverAddressBooks, + fetchCardResource: mocks.fetchCardResource, + putCardResource: mocks.putCardResource, +})); +const { + CARDDAV_CONTACT_ERROR_STATUS, + CardDavAmbiguousWriteError, + CardDavConflictError, + createContact, + createContactFromVCard, + deleteContact, + deleteContactFromVCard, + exportExistingContact, + fetchCreated, + recoverPendingCarddavMutations, + replaceContactFromVCard, + updateContact, +} = await import('./carddavContactService.js'); +const { CardDavError } = await vi.importActual('./carddavTransport.js'); + +const USER_ID = '00000000-0000-4000-8000-000000000001'; +const BOOK_ID = '00000000-0000-4000-8000-000000000002'; +const LOCAL_BOOK_ID = '00000000-0000-4000-8000-000000000003'; +const CONTACT_ID = '00000000-0000-4000-8000-000000000004'; +const UID = '00000000-0000-4000-8000-000000000005'; +const CONFLICT_ID = '00000000-0000-4000-8000-000000000006'; +const BOOK_URL = 'https://dav.example.test/books/default/'; +const HREF = `${BOOK_URL}${UID}.vcf`; +const BASE_VCARD = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:Before\r\nEND:VCARD\r\n`; +const CANONICAL_VCARD = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:After\r\nEND:VCARD\r\n`; + +it('exports the shared CardDAV contact error status mappings', () => { + expect(CARDDAV_CONTACT_ERROR_STATUS).toEqual({ + ERR_CONTACT_VALIDATION: 400, + ERR_CONTACT_UID_MISMATCH: 400, + ERR_CONTACT_NOT_FOUND: 404, + ERR_ADDRESS_BOOK_NOT_FOUND: 404, + ERR_CONTACT_EXISTS: 409, + ERR_CARDDAV_CONFLICT: 409, + ERR_CARDDAV_READ_ONLY: 403, + ERR_CARDDAV_FINAL_FENCE: 503, + ERR_CARDDAV_STALE_GENERATION: 503, + ERR_CARDDAV_AMBIGUOUS_WRITE: 409, + ERR_CARDDAV_PENDING_INTENT: 409, + '23505': 409, + }); +}); + +const draft = (overrides = {}) => ({ + displayName: 'After', + firstName: null, + lastName: null, + emails: [{ value: 'after@example.test', type: 'work', primary: true }], + phones: [], + organization: null, + notes: null, + additionalFields: [], + ...overrides, +}); + +const confirmedRow = (overrides = {}) => ({ + id: CONTACT_ID, + uid: UID, + display_name: 'After', + first_name: null, + last_name: null, + primary_email: 'after@example.test', + emails: draft().emails, + phones: [], + organization: null, + notes: null, + photo_data: null, + additional_fields: [], + is_auto: false, + send_count: 0, + last_sent: null, + etag: 'local-etag-after', + created_at: new Date('2026-07-12T00:00:00Z'), + updated_at: new Date('2026-07-12T00:00:00Z'), + ...overrides, +}); + +const integration = { + id: '00000000-0000-4000-8000-000000000007', + config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'secret', + connectionGeneration: 'generation-1', + }, +}; + +const mapped = (overrides = {}) => ({ + id: CONTACT_ID, + uid: UID, + address_book_id: BOOK_ID, + user_id: USER_ID, + vcard: BASE_VCARD, + etag: 'local-etag-before', + display_name: 'Before', + first_name: null, + last_name: null, + primary_email: 'before@example.test', + emails: [{ value: 'before@example.test', type: 'work', primary: true }], + phones: [], + organization: null, + notes: null, + photo_data: null, + additional_fields: [], + source: 'carddav', + external_url: BOOK_URL, + remote_create_capability: 'allowed', + remote_update_capability: 'allowed', + remote_delete_capability: 'allowed', + href: HREF, + remote_etag: '"remote-1"', + mapping_status: 'synced', + vcard_version: '3.0', + remote_semantic_hash: 'remote-hash-before', + local_contact_hash: 'local-hash-before', + mapping_revision: '3', + ...overrides, +}); + +const book = (overrides = {}) => ({ + url: BOOK_URL, + displayName: 'Default', + capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' }, + addressData: [{ contentType: 'text/vcard', version: '3.0' }], + ...overrides, +}); + +const mappedLocalHash = (contact = mapped()) => localContactHash({ + uid: contact.uid, + displayName: contact.display_name, + firstName: contact.first_name, + lastName: contact.last_name, + emails: contact.emails, + phones: contact.phones, + organization: contact.organization, + notes: contact.notes, + photoData: contact.photo_data, + additionalFields: contact.additional_fields, +}); + +const pendingUpdate = (vcard, overrides = {}) => mapped({ + mapping_status: 'pending_push', + mapping_revision: '4', + pending_operation: 'update', + pending_vcard: vcard, + pending_local_hash: mappedLocalHash(), + pending_remote_semantic_hash: semanticVCardHash(parseVCardDocument(vcard)), + pending_started_at: new Date('2026-07-12T12:00:00.000Z'), + ...overrides, +}); + +const pendingDelete = (overrides = {}) => mapped({ + mapping_status: 'pending_push', + mapping_revision: '4', + pending_operation: 'delete', + pending_vcard: null, + pending_local_hash: mappedLocalHash(), + pending_remote_semantic_hash: null, + pending_started_at: new Date('2026-07-12T12:00:00.000Z'), + ...overrides, +}); + +function protocolMock(mock, result) { + mock.mockImplementation(async () => { + expect(runtime.inTransaction).toBe(false); + runtime.events.push(mock === mocks.putCardResource ? 'remote:put' + : mock === mocks.fetchCardResource ? 'remote:get' : 'remote:delete'); + if (result instanceof Error) throw result; + return typeof result === 'function' ? result() : result; + }); +} + +function transactions(...handlers) { + let index = 0; + mocks.withTransaction.mockImplementation(async callback => { + runtime.inTransaction = true; + runtime.events.push(`tx:${index + 1}:begin`); + const client = { query: vi.fn(handlers[index++] || (async () => ({ rows: [], rowCount: 0 }))) }; + try { + const result = await callback(client); + runtime.events.push(`tx:${index}:commit`); + return result; + } finally { + runtime.inTransaction = false; + } + }); +} + +function preflightHandler({ contact = mapped(), connected = true } = {}) { + return async sql => { + const activeContact = typeof contact === 'function' ? contact() : contact; + if (sql.includes('FROM user_integrations')) return { rows: connected ? [integration] : [] }; + if (sql.includes('FROM contacts c')) return { rows: activeContact ? [activeContact] : [] }; + if (sql.includes('FROM carddav_remote_objects mapping') && sql.includes('FOR UPDATE')) { + return { rows: activeContact ? [activeContact] : [] }; + } + if (sql.includes('UPDATE carddav_remote_objects')) { + return { rows: [{ mapping_revision: '4' }], rowCount: 1 }; + } + if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: [confirmedRow()], rowCount: 1 }; + if (sql.includes('DELETE FROM contacts')) { + return { rows: [{ address_book_id: activeContact.address_book_id }], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }; +} + +function commitHandler({ row = confirmedRow(), revisionMatches = true, mapping } = {}) { + return async sql => { + const attemptedVCard = mocks.putCardResource.mock.calls[0]?.[0]?.vcard; + const activeMapping = typeof mapping === 'function' + ? mapping() + : mapping ?? (attemptedVCard ? pendingUpdate(attemptedVCard) : pendingDelete()); + if (sql.startsWith('SET TRANSACTION')) return { rows: [], rowCount: 0 }; + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) { + return { rows: revisionMatches ? [activeMapping] : [mapped({ mapping_revision: '5' })] }; + } + if (sql.includes('FROM contacts c')) return { rows: [activeMapping] }; + if (sql.includes('INSERT INTO address_books')) { + return { rows: [{ id: sql.includes("'Personal'") ? LOCAL_BOOK_ID : BOOK_ID }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_remote_objects')) { + return { rows: [{ mapping_revision: '0' }], rowCount: 1 }; + } + if (sql.includes('UPDATE carddav_remote_objects')) { + return { rows: [{ mapping_revision: '4' }], rowCount: 1 }; + } + if (sql.includes('DELETE FROM carddav_remote_objects')) { + return { rows: [{ mapping_revision: '3' }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_conflicts')) { + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: row ? [row] : [], rowCount: row ? 1 : 0 }; + if (sql.includes('DELETE FROM contacts')) return { rows: [], rowCount: 1 }; + return { rows: [], rowCount: 1 }; + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + runtime.inTransaction = false; + runtime.events = []; + mocks.randomUUID.mockReturnValue(UID); +}); + +describe('local-only contact mutations', () => { + it('creates locally without discovery when no remote account exists', async () => { + const row = confirmedRow(); + transactions( + async sql => (sql.includes('FROM user_integrations') + ? { rows: [] } + : { rows: [], rowCount: 1 }), + async sql => { + if (sql.includes('INSERT INTO address_books')) return { rows: [{ id: LOCAL_BOOK_ID }] }; + if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: [row], rowCount: 1 }; + return { rows: [], rowCount: 1 }; + }, + ); + + await expect(createContact(USER_ID, draft())).resolves.toEqual(row); + expect(mocks.discoverAddressBooks).not.toHaveBeenCalled(); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + }); + + it('updates and deletes unmapped contacts in one local transaction', async () => { + transactions( + preflightHandler({ contact: mapped({ href: null, source: 'local' }) }), + preflightHandler({ contact: mapped({ href: null, source: 'local' }) }), + ); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toMatchObject({ id: CONTACT_ID }); + await expect(deleteContact(USER_ID, CONTACT_ID)).resolves.toEqual({ ok: true }); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + expect(mocks.deleteCardResource).not.toHaveBeenCalled(); + }); + + it('atomically fences local CardDAV replaces with the supplied ETag', async () => { + let updates = 0; + const updateSql = []; + const handler = async (sql, params) => { + if (sql.includes('FROM user_integrations')) return { rows: [] }; + if (sql.includes('FROM contacts c')) { + return { rows: [mapped({ href: null, source: 'local', address_book_id: LOCAL_BOOK_ID })] }; + } + if (sql.includes('UPDATE contacts SET')) { + updates++; + updateSql.push({ sql, params }); + return updates === 1 + ? { rows: [confirmedRow()], rowCount: 1 } + : { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 1 }; + }; + transactions(handler, handler); + + const mutation = { + localAddressBookId: LOCAL_BOOK_ID, + uid: UID, + rawVCard: CANONICAL_VCARD, + expectedLocalEtag: 'local-etag-before', + }; + await expect(replaceContactFromVCard(USER_ID, mutation)).resolves.toEqual(confirmedRow()); + await expect(replaceContactFromVCard(USER_ID, mutation)).rejects.toMatchObject({ + code: 'ERR_LOCAL_ETAG_MISMATCH', + }); + + expect(updateSql).toHaveLength(2); + expect(updateSql.every(({ sql }) => /AND etag = \$16/.test(sql))).toBe(true); + expect(updateSql.every(({ params }) => params.at(-1) === 'local-etag-before')).toBe(true); + }); + + it('atomically fences local CardDAV deletes with the supplied ETag', async () => { + let deletes = 0; + const deleteSql = []; + const handler = async (sql, params) => { + if (sql.includes('FROM user_integrations')) return { rows: [] }; + if (sql.includes('FROM contacts c')) { + return { rows: [mapped({ href: null, source: 'local', address_book_id: LOCAL_BOOK_ID })] }; + } + if (sql.includes('DELETE FROM contacts')) { + deletes++; + deleteSql.push({ sql, params }); + return { rows: [], rowCount: deletes === 1 ? 1 : 0 }; + } + return { rows: [], rowCount: 1 }; + }; + transactions(handler, handler); + + const mutation = { + localAddressBookId: LOCAL_BOOK_ID, + uid: UID, + expectedLocalEtag: 'local-etag-before', + }; + await expect(deleteContactFromVCard(USER_ID, mutation)).resolves.toEqual({ ok: true }); + await expect(deleteContactFromVCard(USER_ID, mutation)).rejects.toMatchObject({ + code: 'ERR_LOCAL_ETAG_MISMATCH', + }); + + expect(deleteSql).toHaveLength(2); + expect(deleteSql.every(({ sql }) => /AND etag = \$3/.test(sql))).toBe(true); + expect(deleteSql.every(({ params }) => params.at(-1) === 'local-etag-before')).toBe(true); + }); +}); + +describe('remote-first mapped lifecycle', () => { + it.each([ + ['create', () => createContact(USER_ID, draft())], + ['update', () => updateContact(USER_ID, CONTACT_ID, draft())], + ['delete', () => deleteContact(USER_ID, CONTACT_ID)], + ])('blocks %s before pending intent or network while Retry-After is active', async ( + operation, + mutate, + ) => { + const retryAfterAt = '2999-07-12T12:00:00.000Z'; + const throttledIntegration = { + ...integration, + config: { ...integration.config, retryAfterAt }, + }; + transactions(async sql => { + if (sql.includes('FROM user_integrations')) return { rows: [throttledIntegration] }; + if (sql.includes('FROM contacts c')) return { rows: [mapped()] }; + return { rows: [], rowCount: 1 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + + await expect(mutate()).rejects.toMatchObject({ + name: 'CardDavError', + operation, + retryAfterAt, + status: 429, + }); + + expect(mocks.discoverAddressBooks).not.toHaveBeenCalled(); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + expect(mocks.deleteCardResource).not.toHaveBeenCalled(); + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + }); + + it('skips pending-intent recovery on the transitional schema', async () => { + mocks.query.mockImplementation(async sql => { + if (sql.includes('information_schema.columns')) { + return { rows: [{ supports_pending_intent: false }] }; + } + throw new Error('queried pending-intent columns on the transitional schema'); + }); + + await expect(recoverPendingCarddavMutations(USER_ID, { integration })) + .resolves.toEqual([]); + + expect(mocks.query).toHaveBeenCalledOnce(); + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + }); + + it('loads pending intents on the contracted schema', async () => { + mocks.query.mockImplementation(async sql => { + if (sql.includes('information_schema.columns')) { + return { rows: [{ supports_pending_intent: true }] }; + } + if (sql.includes('mapping.pending_operation')) return { rows: [] }; + throw new Error('unexpected pending-intent recovery query'); + }); + + await expect(recoverPendingCarddavMutations(USER_ID, { integration })) + .resolves.toEqual([]); + + expect(mocks.query).toHaveBeenCalledTimes(2); + expect(mocks.query.mock.calls[1][0]).toContain('mapping.pending_operation'); + }); + + it('updates only after preflight commit, conditional PUT, and canonical GET', async () => { + transactions(preflightHandler(), commitHandler()); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' }); + protocolMock(mocks.fetchCardResource, () => ({ + href: HREF, + etag: '"remote-2"', + vcard: mocks.putCardResource.mock.calls[0][0].vcard, + })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow()); + + expect(runtime.events).toEqual([ + 'tx:1:begin', 'tx:1:commit', 'remote:put', 'remote:get', 'tx:2:begin', 'tx:2:commit', + ]); + expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ + url: BOOK_URL, + href: HREF, + etag: '"remote-1"', + vcard: expect.stringContaining('FN:After'), + })); + }); + + it.each([ + ['remote write', mocks.putCardResource], + ['canonical fetch', mocks.fetchCardResource], + ])('does not start the compare-and-commit after %s failure', async (_label, failedMock) => { + transactions(preflightHandler()); + protocolMock(mocks.putCardResource, failedMock === mocks.putCardResource + ? new CardDavError('write failed', { status: 500 }) + : { href: HREF, etag: '"intermediate"' }); + protocolMock(mocks.fetchCardResource, new CardDavError('fetch failed', { status: 500 })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf( + CardDavAmbiguousWriteError, + ); + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + }); + + it('returns a typed ambiguous result and preserves baselines on revision mismatch', async () => { + const finalClient = { query: vi.fn(commitHandler({ revisionMatches: false })) }; + mocks.withTransaction + .mockImplementationOnce(async callback => callback({ query: vi.fn(preflightHandler()) })) + .mockImplementationOnce(async callback => callback(finalClient)); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-2"', + vcard: CANONICAL_VCARD, + }); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf( + CardDavAmbiguousWriteError, + ); + expect(finalClient.query.mock.calls.some(([sql]) => ( + /UPDATE carddav_remote_objects/.test(sql) && /remote_semantic_hash/.test(sql) + ))).toBe(false); + }); + + it('does not compensate or repeat a confirmed remote write when final commit fails', async () => { + transactions(preflightHandler(), async sql => { + if (sql.startsWith('SET TRANSACTION')) return { rows: [] }; + throw new Error('commit path unavailable'); + }); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-2"', + vcard: CANONICAL_VCARD, + }); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf( + CardDavAmbiguousWriteError, + ); + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.deleteCardResource).not.toHaveBeenCalled(); + }); + + it('treats remote delete 404 as confirmed before removing local state', async () => { + transactions(preflightHandler(), commitHandler({ row: null })); + protocolMock(mocks.deleteCardResource, { href: HREF, status: 404 }); + protocolMock(mocks.fetchCardResource, new CardDavError('missing', { status: 404 })); + + await expect(deleteContact(USER_ID, CONTACT_ID)).resolves.toEqual({ ok: true }); + expect(runtime.events).toEqual([ + 'tx:1:begin', 'tx:1:commit', 'remote:delete', 'remote:get', + 'tx:2:begin', 'tx:2:commit', + ]); + }); + + it('fails a denied mapped operation before local or remote mutation', async () => { + transactions(preflightHandler({ + contact: mapped({ remote_update_capability: 'denied' }), + })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toMatchObject({ + code: 'ERR_CARDDAV_READ_ONLY', + }); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + }); + + it('rejects a different interactive update while an intent awaits recovery', async () => { + transactions(preflightHandler({ contact: pendingUpdate(CANONICAL_VCARD) })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft({ displayName: 'Different Edit' }))) + .rejects.toMatchObject({ code: 'ERR_CARDDAV_PENDING_INTENT' }); + + expect(mocks.putCardResource).not.toHaveBeenCalled(); + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + }); + + it('recovers a timeout after an applied PUT with one PUT total', async () => { + let attemptedVCard; + transactions( + preflightHandler(), + async (sql, params) => { + if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) { + return { rows: [pendingUpdate(attemptedVCard)] }; + } + return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params); + }, + ); + mocks.putCardResource.mockImplementation(async options => { + expect(runtime.inTransaction).toBe(false); + runtime.events.push('remote:put'); + attemptedVCard = options.vcard; + throw new CardDavError('response timed out', { operation: 'update' }); + }); + mocks.fetchCardResource.mockImplementation(async () => ({ + href: HREF, + etag: '"remote-2"', + vcard: attemptedVCard, + })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow()); + + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.fetchCardResource).toHaveBeenCalledOnce(); + }); + + it('recovers a canonical GET failure on retry without a second PUT', async () => { + let attemptedVCard; + transactions( + preflightHandler(), + preflightHandler({ contact: () => pendingUpdate(attemptedVCard) }), + async (sql, params) => { + if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) { + return { rows: [pendingUpdate(attemptedVCard)] }; + } + return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params); + }, + ); + mocks.putCardResource.mockImplementation(async options => { + attemptedVCard = options.vcard; + return { href: HREF, etag: '"intermediate"' }; + }); + mocks.fetchCardResource + .mockRejectedValueOnce(new CardDavError('canonical GET failed', { status: 500 })) + .mockImplementationOnce(async () => ({ + href: HREF, + etag: '"remote-2"', + vcard: attemptedVCard, + })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf( + CardDavAmbiguousWriteError, + ); + await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow()); + + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + }); + + it('recovers a failed final transaction on retry without a second PUT', async () => { + let attemptedVCard; + transactions( + preflightHandler(), + async () => { throw new Error('final transaction unavailable'); }, + preflightHandler({ contact: () => pendingUpdate(attemptedVCard) }), + async (sql, params) => { + if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) { + return { rows: [pendingUpdate(attemptedVCard)] }; + } + return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params); + }, + ); + mocks.putCardResource.mockImplementation(async options => { + attemptedVCard = options.vcard; + return { href: HREF, etag: '"intermediate"' }; + }); + mocks.fetchCardResource.mockImplementation(async () => ({ + href: HREF, + etag: '"remote-2"', + vcard: attemptedVCard, + })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf( + CardDavAmbiguousWriteError, + ); + await expect(updateContact(USER_ID, CONTACT_ID, draft())).resolves.toEqual(confirmedRow()); + + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + }); + + it('recovers an applied DELETE with a lost response using one DELETE total', async () => { + transactions(preflightHandler(), commitHandler({ row: null, mapping: pendingDelete() })); + protocolMock( + mocks.deleteCardResource, + new CardDavError('response timed out', { operation: 'delete' }), + ); + protocolMock(mocks.fetchCardResource, new CardDavError('missing', { status: 404 })); + + await expect(deleteContact(USER_ID, CONTACT_ID)).resolves.toEqual({ ok: true }); + + expect(mocks.deleteCardResource).toHaveBeenCalledOnce(); + expect(mocks.fetchCardResource).toHaveBeenCalledOnce(); + }); + + it('turns a concurrent local edit after intent creation into exactly one lossless conflict', async () => { + let attemptedVCard; + let conflictInsert; + // The retained remote vCard carries unmodeled server properties the lossy + // contacts.vcard drops. + const retained = `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:Before\r\nCATEGORIES:VIP\r\nX-KEEP:me\r\nEND:VCARD\r\n`; + const concurrentlyEdited = () => pendingUpdate(attemptedVCard, { + display_name: 'Concurrent Local Edit', + mapping_vcard: retained, + }); + transactions( + preflightHandler({ contact: () => mapped({ mapping_vcard: retained }) }), + preflightHandler({ contact: concurrentlyEdited }), + async (sql, params) => { + if (sql.includes('FROM carddav_remote_objects') && sql.includes('FOR UPDATE')) { + return { rows: [pendingUpdate(attemptedVCard)] }; + } + if (sql.includes('FROM contacts c')) return { rows: [concurrentlyEdited()] }; + if (sql.includes('INSERT INTO carddav_conflicts')) { + conflictInsert = params; + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + return commitHandler({ mapping: pendingUpdate(attemptedVCard) })(sql, params); + }, + ); + mocks.putCardResource.mockImplementation(async options => { + attemptedVCard = options.vcard; + return { href: HREF, etag: '"intermediate"' }; + }); + mocks.fetchCardResource + .mockRejectedValueOnce(new CardDavError('canonical GET failed', { status: 500 })) + .mockImplementationOnce(async () => ({ + href: HREF, + etag: '"remote-2"', + vcard: attemptedVCard, + })); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toBeInstanceOf( + CardDavAmbiguousWriteError, + ); + const error = await updateContact(USER_ID, CONTACT_ID, draft()).catch(value => value); + + expect(error).toBeInstanceOf(CardDavConflictError); + expect(error.conflictId).toBe(CONFLICT_ID); + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + // The preserved-current-local snapshot overlays the edited contact onto the + // retained remote vCard: the local edit AND the unmodeled properties survive, so + // a later keep-mailflow resolution does not strip them. + const localVCard = conflictInsert[5]; + expect(localVCard).toContain('FN:Concurrent Local Edit'); + expect(localVCard).toContain('CATEGORIES:VIP'); + expect(localVCard).toContain('X-KEEP:me'); + }); +}); + +describe('stale remote writes', () => { + it('records one durable conflict with the rejected draft and latest remote snapshot', async () => { + const stale = new CardDavError('precondition failed', { status: 412, operation: 'update' }); + const conflictClient = { query: vi.fn(commitHandler()) }; + mocks.withTransaction + .mockImplementationOnce(async callback => callback({ query: vi.fn(preflightHandler()) })) + .mockImplementationOnce(async callback => callback(conflictClient)); + protocolMock(mocks.putCardResource, stale); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-2"', + vcard: BASE_VCARD, + }); + + const error = await updateContact(USER_ID, CONTACT_ID, draft()).catch(value => value); + expect(error).toBeInstanceOf(CardDavConflictError); + expect(error.conflictId).toBe(CONFLICT_ID); + const conflictInsert = conflictClient.query.mock.calls.find(([sql]) => ( + sql.includes('INSERT INTO carddav_conflicts') + )); + expect(conflictInsert[1]).toEqual([ + BOOK_ID, + HREF, + USER_ID, + 'local-hash-before', + '"remote-2"', + expect.stringContaining('FN:After'), + BASE_VCARD, + false, + false, + ]); + }); + + it.each([ + ['malformed', 'not a vCard', /BEGIN:VCARD/], + [ + 'oversized', + `BEGIN:VCARD\r\nVERSION:3.0\r\nUID:${UID}\r\nFN:${'x'.repeat(1024 * 1024)}\r\nEND:VCARD\r\n`, + /1 MiB|64 KiB/, + ], + ])('rejects a %s latest remote vCard before opening the conflict transaction', async ( + _kind, + remoteVCard, + expectedMessage, + ) => { + const stale = new CardDavError('precondition failed', { status: 412, operation: 'update' }); + transactions(preflightHandler(), commitHandler()); + protocolMock(mocks.putCardResource, stale); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-2"', + vcard: remoteVCard, + }); + + await expect(updateContact(USER_ID, CONTACT_ID, draft())).rejects.toThrow(expectedMessage); + + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + }); +}); + +describe('remote creates and export', () => { + it.each([ + { + label: 'found', + result: { href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD }, + expected: { kind: 'found', remote: expect.objectContaining({ href: HREF }) }, + }, + { + label: 'missing', + result: new CardDavError('missing', { status: 404 }), + expected: { kind: 'missing' }, + }, + { + label: 'unknown', + result: new CardDavError('failed', { status: 500 }), + expected: { kind: 'unknown', cause: expect.objectContaining({ status: 500 }) }, + }, + ])('classifies the deterministic create fetch as $label', async ({ result, expected }) => { + protocolMock(mocks.fetchCardResource, result); + + await expect(fetchCreated({ + book: book(), + href: `${UID}.vcf`, + uid: UID, + creds: { username: 'user', password: 'secret', allowPrivate: false }, + })).resolves.toEqual(expected); + }); + + it.each([409, 500])('does not retry an authoritative create HTTP %i result', async status => { + transactions(preflightHandler({ contact: null })); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + protocolMock(mocks.putCardResource, new CardDavError('create rejected', { + status, + operation: 'create', + })); + + await expect(createContact(USER_ID, draft())).rejects.toMatchObject({ status }); + + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + }); + + it('validates an interactive draft before discovery or any external request', async () => { + transactions(preflightHandler({ contact: null })); + + await expect(createContact(USER_ID, draft({ displayName: '', emails: [] }))) + .rejects.toMatchObject({ code: 'ERR_CONTACT_VALIDATION' }); + + expect(mocks.withTransaction).not.toHaveBeenCalled(); + expect(mocks.discoverAddressBooks).not.toHaveBeenCalled(); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + }); + + it('discovers, selects the first writable book, and uses one UID for the href and vCard', async () => { + const denied = book({ + url: 'https://dav.example.test/books/denied/', + capabilities: { create: 'denied', update: 'denied', delete: 'denied' }, + }); + const versionFour = book({ + addressData: [{ contentType: 'text/vcard', version: '4.0' }], + }); + transactions(preflightHandler({ contact: null }), commitHandler()); + mocks.discoverAddressBooks.mockResolvedValue([denied, versionFour]); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: CANONICAL_VCARD.replace('VERSION:3.0', 'VERSION:4.0'), + }); + + await createContact(USER_ID, draft()); + + expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce(); + expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ + url: BOOK_URL, + href: `${UID}.vcf`, + vcard: expect.stringMatching(new RegExp(`VERSION:4\\.0[\\s\\S]+UID:${UID}`)), + })); + }); + + it('checks the deterministic UID href after an ambiguous create before retrying PUT', async () => { + transactions(preflightHandler({ contact: null }), commitHandler()); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + protocolMock(mocks.putCardResource, new CardDavError('connection reset', { operation: 'create' })); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: CANONICAL_VCARD, + }); + + await createContact(USER_ID, draft()); + + expect(mocks.fetchCardResource).toHaveBeenCalledOnce(); + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + }); + + it('retries the same deterministic href only after an ambiguous PUT is confirmed absent', async () => { + transactions(preflightHandler({ contact: null }), commitHandler()); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + mocks.putCardResource + .mockRejectedValueOnce(new CardDavError('connection reset', { operation: 'create' })) + .mockResolvedValueOnce({ href: HREF, etag: '"created"' }); + mocks.fetchCardResource + .mockRejectedValueOnce(new CardDavError('missing', { status: 404 })) + .mockResolvedValueOnce({ href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD }); + + await createContact(USER_ID, draft()); + + expect(mocks.putCardResource).toHaveBeenCalledTimes(2); + expect(mocks.putCardResource.mock.calls.map(([options]) => options.href)) + .toEqual([`${UID}.vcf`, `${UID}.vcf`]); + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + }); + + it('recovers an ambiguous bounded retry with one final deterministic GET', async () => { + transactions(preflightHandler({ contact: null }), commitHandler()); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + mocks.putCardResource + .mockRejectedValueOnce(new CardDavError('first reset', { operation: 'create' })) + .mockRejectedValueOnce(new CardDavError('second reset', { operation: 'create' })); + mocks.fetchCardResource + .mockRejectedValueOnce(new CardDavError('missing', { status: 404 })) + .mockResolvedValueOnce({ href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD }); + + await createContact(USER_ID, draft()); + + expect(mocks.putCardResource).toHaveBeenCalledTimes(2); + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + }); + + it('recovers retry 412 with one final deterministic GET and one local materialization', async () => { + let contactInserts = 0; + let mappingUpserts = 0; + transactions( + preflightHandler({ contact: null }), + async sql => { + if (sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + if (sql.includes("VALUES ($1, 'Personal')")) return { rows: [{ id: LOCAL_BOOK_ID }] }; + if (sql.includes('INSERT INTO address_books')) return { rows: [{ id: BOOK_ID }] }; + if (sql.includes('INSERT INTO contacts')) { + contactInserts++; + return { rows: [confirmedRow()], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_remote_objects')) mappingUpserts++; + return { rows: [], rowCount: 1 }; + }, + ); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + mocks.putCardResource + .mockRejectedValueOnce(new CardDavError('connection reset', { operation: 'create' })) + .mockRejectedValueOnce(new CardDavError('already exists', { status: 412, operation: 'create' })); + mocks.fetchCardResource + .mockRejectedValueOnce(new CardDavError('missing', { status: 404 })) + .mockResolvedValueOnce({ href: HREF, etag: '"remote-1"', vcard: CANONICAL_VCARD }); + + await createContact(USER_ID, draft()); + + expect(mocks.putCardResource).toHaveBeenCalledTimes(2); + expect(mocks.fetchCardResource).toHaveBeenCalledTimes(2); + expect(contactInserts).toBe(1); + expect(mappingUpserts).toBe(1); + }); + + it('defaults to vCard 3.0 when discovery advertises mixed versions', async () => { + transactions(preflightHandler({ contact: null }), commitHandler()); + mocks.discoverAddressBooks.mockResolvedValue([book({ + addressData: [ + { contentType: 'text/x-vcard', version: '3.0' }, + { contentType: 'text/vcard', version: '4.0' }, + ], + })]); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: CANONICAL_VCARD, + }); + + await createContact(USER_ID, draft()); + + expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ + vcard: expect.stringContaining('VERSION:3.0'), + })); + }); + + it('exports with the caller snapshot and does not rediscover', async () => { + transactions(preflightHandler({ contact: mapped({ href: null, source: 'local' }) }), commitHandler()); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: CANONICAL_VCARD, + }); + + await exportExistingContact(USER_ID, CONTACT_ID, { books: [book()] }); + + expect(mocks.discoverAddressBooks).not.toHaveBeenCalled(); + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + }); + + it('rejects an export planned by a stale connection generation before remote I/O', async () => { + const replacement = { + ...integration, + config: { ...integration.config, connectionGeneration: 'generation-2' }, + }; + transactions(async sql => { + if (sql.includes('FROM user_integrations')) return { rows: [replacement] }; + if (sql.includes('FROM contacts c')) { + return { rows: [mapped({ href: null, source: 'local' })] }; + } + return { rows: [], rowCount: 1 }; + }); + + await expect(exportExistingContact(USER_ID, CONTACT_ID, { + books: [book()], + expectedGeneration: 'generation-1', + })).rejects.toMatchObject({ + code: 'ERR_CARDDAV_STALE_GENERATION', + expectedConnectionGeneration: 'generation-1', + actualConnectionGeneration: 'generation-2', + }); + expect(mocks.discoverAddressBooks).not.toHaveBeenCalled(); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + }); + + it.each([ + { + sourceVersion: '3.0', + addressData: [{ contentType: 'text/vcard', version: '4.0' }], + expectedVersion: '4.0', + }, + { + sourceVersion: '4.0', + addressData: [ + { contentType: 'text/x-vcard', version: '3.0' }, + { contentType: 'text/vcard', version: '4.0' }, + ], + expectedVersion: '3.0', + }, + ])('exports retained vCard $sourceVersion as the selected book version $expectedVersion', async ({ + sourceVersion, + addressData, + expectedVersion, + }) => { + const retained = BASE_VCARD.replace('VERSION:3.0', `VERSION:${sourceVersion}`); + const canonical = CANONICAL_VCARD.replace('VERSION:3.0', `VERSION:${expectedVersion}`); + transactions( + preflightHandler({ contact: mapped({ href: null, source: 'local', vcard: retained }) }), + commitHandler(), + ); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: canonical, + }); + + await exportExistingContact(USER_ID, CONTACT_ID, { books: [book({ addressData })] }); + + expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ + vcard: expect.stringContaining(`VERSION:${expectedVersion}`), + })); + }); + + it('fences export finalization against a concurrent local contact change', async () => { + let sawLocalEtagFence = false; + transactions( + preflightHandler({ contact: mapped({ href: null, source: 'local' }) }), + async sql => { + if (sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + if (sql.includes('INSERT INTO address_books')) return { rows: [{ id: BOOK_ID }] }; + if (sql.includes('UPDATE contacts')) { + sawLocalEtagFence = /AND etag = \$16/.test(sql); + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 1 }; + }, + ); + mocks.discoverAddressBooks.mockImplementation(() => { + throw new Error('export must use its supplied snapshot'); + }); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: CANONICAL_VCARD, + }); + + await expect(exportExistingContact(USER_ID, CONTACT_ID, { books: [book()] })) + .rejects.toBeInstanceOf(CardDavAmbiguousWriteError); + expect(sawLocalEtagFence).toBe(true); + }); + + it('allocates a non-conflicting local name when materializing the selected remote book', async () => { + let remoteBookAttempts = 0; + transactions( + preflightHandler({ contact: null }), + async sql => { + if (sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + if (sql.includes("VALUES ($1, 'Personal')")) return { rows: [{ id: LOCAL_BOOK_ID }] }; + if (sql.includes('INSERT INTO address_books')) { + remoteBookAttempts++; + if (remoteBookAttempts === 1) { + throw Object.assign(new Error('duplicate book name'), { code: '23505' }); + } + return { rows: [{ id: BOOK_ID }] }; + } + if (/RETURNING\s+id,\s*uid/.test(sql)) { + return { rows: [confirmedRow()], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }, + ); + mocks.discoverAddressBooks.mockResolvedValue([book({ displayName: 'Personal' })]); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: CANONICAL_VCARD, + }); + + await createContact(USER_ID, draft()); + + expect(remoteBookAttempts).toBe(2); + }); + + it('persists only create denial after a real rejected create on an unseen remote book', async () => { + let materializedBook = false; + let capabilitySql = ''; + transactions( + preflightHandler({ contact: null }), + async sql => { + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + if (sql.includes('INSERT INTO address_books')) { + materializedBook = true; + return { rows: [{ id: BOOK_ID }] }; + } + if (sql.includes('UPDATE address_books')) capabilitySql = sql; + return { rows: [], rowCount: 1 }; + }, + ); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + protocolMock(mocks.putCardResource, new CardDavError('forbidden', { + status: 403, + operation: 'create', + })); + + await expect(createContact(USER_ID, draft())).rejects.toMatchObject({ status: 403 }); + + expect(materializedBook).toBe(true); + expect(capabilitySql).toContain("remote_create_capability = 'denied'"); + expect(capabilitySql).not.toMatch(/remote_(?:update|delete)_capability = 'denied'/); + }); + + it('preserves existing update and delete denials when create is rejected', async () => { + let materializeSql = ''; + let denialSql = ''; + transactions( + preflightHandler({ contact: null }), + async sql => { + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + if (sql.includes('INSERT INTO address_books')) { + materializeSql = sql; + return { rows: [{ id: BOOK_ID }] }; + } + if (sql.includes('UPDATE address_books')) denialSql = sql; + return { rows: [], rowCount: 1 }; + }, + ); + mocks.discoverAddressBooks.mockResolvedValue([book({ + capabilities: { create: 'unknown', update: 'allowed', delete: 'unknown' }, + })]); + protocolMock(mocks.putCardResource, new CardDavError('forbidden', { + status: 403, + operation: 'create', + })); + + await expect(createContact(USER_ID, draft())).rejects.toMatchObject({ status: 403 }); + + const conflictUpdate = materializeSql.split('DO UPDATE SET')[1]; + expect(conflictUpdate).not.toMatch(/remote_(?:create|update|delete)_capability/); + expect(denialSql).toContain("remote_create_capability = 'denied'"); + expect(denialSql).not.toMatch(/remote_(?:update|delete)_capability/); + }); + + it('uses the established CardDAV fallback when a remote book has no display name', async () => { + let storedName = null; + transactions( + preflightHandler({ contact: null }), + async (sql, params) => { + if (sql.startsWith('SET TRANSACTION')) return { rows: [] }; + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + if (sql.includes("VALUES ($1, 'Personal')")) return { rows: [{ id: LOCAL_BOOK_ID }] }; + if (sql.includes('INSERT INTO address_books')) { + storedName = params[1]; + return { rows: [{ id: BOOK_ID }] }; + } + if (sql.includes('INSERT INTO contacts')) { + return { rows: [confirmedRow()], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }, + ); + mocks.discoverAddressBooks.mockResolvedValue([book({ displayName: '' })]); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"created"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-1"', + vcard: CANONICAL_VCARD, + }); + + await createContact(USER_ID, draft()); + + expect(storedName).toBe('CardDAV'); + }); +}); + +describe('MailFlow CardDAV-server seams', () => { + it('stores the preferred email when it is not listed first', async () => { + const rawVCard = BASE_VCARD + .replace('VERSION:3.0', 'VERSION:4.0') + .replace( + 'FN:Before\r\n', + 'FN:Before\r\nEMAIL:first@example.test\r\nEMAIL;PREF=1: SECOND@EXAMPLE.TEST \r\n', + ); + let insertParams; + transactions(async (sql, params) => { + if (sql.includes('FROM user_integrations')) return { rows: [] }; + if (sql.includes('FROM address_books')) return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] }; + if (sql.includes('INSERT INTO contacts')) { + insertParams = params; + return { rows: [confirmedRow({ uid: UID })], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }); + + await createContactFromVCard(USER_ID, { + localAddressBookId: LOCAL_BOOK_ID, + uid: UID, + rawVCard, + }); + + expect(insertParams[8]).toBe('second@example.test'); + }); + + it('preserves a client UID and raw vCard when creating through a local book', async () => { + transactions(async sql => { + if (sql.includes('FROM user_integrations')) return { rows: [] }; + if (sql.includes('FROM address_books')) return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] }; + if (/RETURNING\s+id,\s*uid/.test(sql)) return { rows: [confirmedRow({ uid: UID })] }; + return { rows: [], rowCount: 1 }; + }); + + await createContactFromVCard(USER_ID, { + localAddressBookId: LOCAL_BOOK_ID, + uid: UID, + rawVCard: BASE_VCARD, + }); + + const calls = mocks.withTransaction.mock.calls; + expect(calls).toHaveLength(1); + expect(mocks.randomUUID).not.toHaveBeenCalled(); + }); + + it('translates the local book/UID unique race for create-only CardDAV PUT', async () => { + const duplicate = Object.assign(new Error('duplicate contact UID'), { + code: '23505', + constraint: 'contacts_address_book_id_uid_key', + }); + transactions(async sql => { + if (sql.includes('FROM address_books')) { + return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] }; + } + if (sql.includes('FROM contacts c')) return { rows: [] }; + if (sql.includes('FROM user_integrations')) return { rows: [] }; + if (sql.includes('INSERT INTO contacts')) throw duplicate; + return { rows: [], rowCount: 1 }; + }); + + await expect(createContactFromVCard(USER_ID, { + localAddressBookId: LOCAL_BOOK_ID, + uid: UID, + rawVCard: BASE_VCARD, + expectedAbsent: true, + })).rejects.toMatchObject({ code: 'ERR_LOCAL_PRECONDITION_FAILED' }); + }); + + it('leaves unrelated local create SQL failures unchanged', async () => { + const unrelated = Object.assign(new Error('unrelated unique failure'), { + code: '23505', + constraint: 'contacts_pkey', + }); + transactions(async sql => { + if (sql.includes('FROM address_books')) { + return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] }; + } + if (sql.includes('FROM contacts c')) return { rows: [] }; + if (sql.includes('FROM user_integrations')) return { rows: [] }; + if (sql.includes('INSERT INTO contacts')) throw unrelated; + return { rows: [], rowCount: 1 }; + }); + + await expect(createContactFromVCard(USER_ID, { + localAddressBookId: LOCAL_BOOK_ID, + uid: UID, + rawVCard: BASE_VCARD, + expectedAbsent: true, + })).rejects.toBe(unrelated); + }); + + it('translates an authoritative remote create-only 412 without local mutation', async () => { + transactions(async sql => { + if (sql.includes('FROM address_books')) { + return { rows: [{ id: LOCAL_BOOK_ID, source: 'local' }] }; + } + if (sql.includes('FROM contacts c')) return { rows: [] }; + if (sql.includes('FROM user_integrations')) return { rows: [integration] }; + return { rows: [], rowCount: 1 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([book()]); + protocolMock(mocks.putCardResource, new CardDavError('already exists', { + status: 412, + operation: 'create', + })); + + await expect(createContactFromVCard(USER_ID, { + localAddressBookId: LOCAL_BOOK_ID, + uid: UID, + rawVCard: BASE_VCARD, + expectedAbsent: true, + })).rejects.toMatchObject({ code: 'ERR_LOCAL_PRECONDITION_FAILED' }); + + expect(mocks.putCardResource).toHaveBeenCalledOnce(); + expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ + href: `${UID}.vcf`, + })); + expect(mocks.fetchCardResource).not.toHaveBeenCalled(); + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + }); + + it('rejects a stale local ETag before an external request', async () => { + transactions(preflightHandler({ contact: mapped({ etag: 'current-local-etag' }) })); + + await expect(replaceContactFromVCard(USER_ID, { + localAddressBookId: BOOK_ID, + uid: UID, + rawVCard: CANONICAL_VCARD, + expectedLocalEtag: 'stale-local-etag', + })).rejects.toMatchObject({ code: 'ERR_LOCAL_ETAG_MISMATCH' }); + expect(mocks.putCardResource).not.toHaveBeenCalled(); + }); + + it('resolves delete ownership by local book plus UID and enforces the ETag', async () => { + transactions(preflightHandler(), commitHandler({ row: null })); + protocolMock(mocks.deleteCardResource, { href: HREF, status: 204 }); + protocolMock(mocks.fetchCardResource, new CardDavError('missing', { status: 404 })); + + await expect(deleteContactFromVCard(USER_ID, { + localAddressBookId: BOOK_ID, + uid: UID, + expectedLocalEtag: 'local-etag-before', + })).resolves.toEqual({ ok: true }); + expect(mocks.deleteCardResource).toHaveBeenCalledOnce(); + }); + + it('selects create versus replace from local ownership instead of a caller contact ID', async () => { + transactions(preflightHandler(), commitHandler()); + protocolMock(mocks.putCardResource, { href: HREF, etag: '"intermediate"' }); + protocolMock(mocks.fetchCardResource, { + href: HREF, + etag: '"remote-2"', + vcard: CANONICAL_VCARD, + }); + + await replaceContactFromVCard(USER_ID, { + localAddressBookId: BOOK_ID, + uid: UID, + rawVCard: CANONICAL_VCARD, + expectedLocalEtag: 'local-etag-before', + }); + + expect(mocks.putCardResource).toHaveBeenCalledWith(expect.objectContaining({ + etag: '"remote-1"', + })); + }); +}); diff --git a/backend/src/services/carddavFixtureServer.js b/backend/src/services/carddavFixtureServer.js new file mode 100644 index 00000000..86e4afc8 --- /dev/null +++ b/backend/src/services/carddavFixtureServer.js @@ -0,0 +1,397 @@ +import http from 'node:http'; +import { xmlEscape } from './carddavXml.js'; + +const PRINCIPAL_PATH = '/principals/fixture-user/'; +const HOME_PATH = '/addressbooks/fixture-user/'; +const BOOK_PATH = `${HOME_PATH}contacts/`; + +function unescapeXml(value) { + return String(value) + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +function multistatus(content) { + return ` + +${content} +`; +} + +function propResponse(href, properties) { + return `${xmlEscape(href)} +${properties} +HTTP/1.1 200 OK`; +} + +function cardResponse(contact) { + return propResponse(contact.href, `${xmlEscape(contact.etag)} +${xmlEscape(contact.vcard)}`); +} + +function removedResponse(href) { + return `${xmlEscape(href)} +HTTP/1.1 404 Not Found`; +} + +function readBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on('data', chunk => chunks.push(chunk)); + request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + request.on('error', reject); + }); +} + +function send(response, status, body = '', headers = {}) { + const responseHeaders = { ...headers }; + if (body && !Object.keys(responseHeaders).some(name => name.toLowerCase() === 'content-type')) { + responseHeaders['Content-Type'] = 'application/xml; charset=utf-8'; + } + response.writeHead(status, responseHeaders); + response.end(body); +} + +export function createCarddavFixtureServer() { + const contacts = new Map(); + const syncResponses = new Map(); + const multigetResponses = []; + const discoveryResponses = []; + const writeResponses = new Map(); + const redirects = new Map(); + let origin; + let writeRevision = 0; + const counters = { + requests: 0, + propfind: 0, + sync: 0, + multiget: 0, + addressbookQuery: 0, + fetch: 0, + create: 0, + update: 0, + delete: 0, + requestUri507: 0, + snapshotFilters: [], + syncTokens: [], + multigetSizes: [], + }; + const requests = []; + + function absoluteHref(href) { + return new URL(href, `${origin}${BOOK_PATH}`).href; + } + + function queueSync(token, scriptedResponse) { + const queue = syncResponses.get(token) || []; + queue.push(scriptedResponse); + syncResponses.set(token, queue); + } + + function takeSync(token) { + const queue = syncResponses.get(token); + if (!queue?.length) { + throw new Error(`No CardDAV fixture response queued for token ${JSON.stringify(token)}`); + } + return queue.shift(); + } + + function takeWrite(method) { + const queue = writeResponses.get(method); + return queue?.shift(); + } + + const server = http.createServer(async (request, response) => { + try { + const body = await readBody(request); + const url = new URL(request.url, origin || 'http://127.0.0.1'); + counters.requests++; + requests.push({ + method: request.method, + origin: url.origin, + path: url.pathname, + authorization: request.headers.authorization, + accept: request.headers.accept, + contentType: request.headers['content-type'], + depth: request.headers.depth, + ifMatch: request.headers['if-match'], + ifNoneMatch: request.headers['if-none-match'], + body, + }); + + const redirectKey = `${request.method} ${url.pathname}`; + const redirectQueue = redirects.get(redirectKey); + if (redirectQueue?.length) { + const redirect = redirectQueue.shift(); + response.writeHead(redirect.status || 301, { Location: redirect.location }); + response.end(); + return; + } + + if (request.method === 'PROPFIND') { + counters.propfind++; + if (url.pathname === '/' || url.pathname === '/dav/') { + return send(response, 207, multistatus(propResponse( + url.pathname, + `${PRINCIPAL_PATH}`, + ))); + } + if (url.pathname === PRINCIPAL_PATH) { + return send(response, 207, multistatus(propResponse( + PRINCIPAL_PATH, + `${HOME_PATH}`, + ))); + } + if (url.pathname === HOME_PATH) { + const scripted = discoveryResponses.shift(); + if (scripted?.status && scripted.status !== 207) { + return send( + response, + scripted.status, + scripted.rawBody || '', + scripted.headers || {}, + ); + } + if (scripted && Object.hasOwn(scripted, 'rawBody')) { + return send(response, scripted.status || 207, scripted.rawBody); + } + const reports = ` + + +`; + const homeResponse = propResponse( + HOME_PATH, + '', + ); + const books = scripted && Object.hasOwn(scripted, 'books') + ? scripted.books + : [{ href: BOOK_PATH, displayName: 'Fixture Contacts' }]; + const bookResponses = books.map(book => { + const privileges = Object.hasOwn(book, 'privileges') + ? book.privileges + : ['bind', 'write-content', 'unbind']; + const addressData = Object.hasOwn(book, 'addressData') + ? book.addressData + : [ + { contentType: 'text/vcard', version: '4.0' }, + { contentType: 'text/vcard', version: '3.0' }, + ]; + const privilegeProperty = privileges === false ? '' : ` +${privileges.map(privilege => ( + `` +)).join('')}`; + const addressDataProperty = addressData === false ? '' : ` +${addressData.map(entry => ( + `` +)).join('')}`; + return propResponse( + book.href || BOOK_PATH, + ` +${xmlEscape(book.displayName || 'Fixture Contacts')}${ + book.reports === false ? '' : reports +}${privilegeProperty}${addressDataProperty}`, + ); + }); + return send(response, 207, multistatus([homeResponse, ...bookResponses].join('\n'))); + } + } + + if (['GET', 'PUT', 'DELETE'].includes(request.method) + && url.pathname.startsWith(HOME_PATH) + && !url.pathname.endsWith('/')) { + const href = url.href; + const existing = contacts.get(href); + const scripted = takeWrite(request.method); + + if (scripted) { + return send( + response, + scripted.status, + scripted.rawBody || '', + scripted.headers || {}, + ); + } + + if (request.method === 'GET') { + counters.fetch++; + if (!existing) return send(response, 404); + return send(response, 200, existing.vcard, { + 'Content-Type': 'text/vcard; charset=utf-8', + ETag: existing.etag, + }); + } + + if (request.method === 'PUT') { + const creating = request.headers['if-none-match'] === '*' + && request.headers['if-match'] == null; + const updating = request.headers['if-match'] != null + && request.headers['if-none-match'] == null; + if (!creating && !updating) return send(response, 428); + if (creating && existing) return send(response, 412); + if (updating && (!existing || request.headers['if-match'] !== existing.etag)) { + return send(response, 412); + } + if (!request.headers['content-type']?.startsWith('text/vcard')) { + return send(response, 415); + } + const etag = `"fixture-write-${++writeRevision}"`; + contacts.set(href, { href, etag, vcard: body }); + counters[creating ? 'create' : 'update']++; + return send(response, creating ? 201 : 204, '', { ETag: etag }); + } + + counters.delete++; + if (!existing) return send(response, 404); + if (request.headers['if-match'] !== existing.etag) return send(response, 412); + contacts.delete(href); + return send(response, 204); + } + + if (request.method === 'REPORT' + && url.pathname.startsWith(HOME_PATH) + && url.pathname !== HOME_PATH) { + if (body.includes('([\s\S]*?)<\/sync-token>/)?.[1] || ''); + counters.syncTokens.push(token); + const scripted = takeSync(token); + if (scripted.waitFor) { + scripted.reached?.(); + await scripted.waitFor; + } + if (Object.hasOwn(scripted, 'rawBody')) { + return send(response, scripted.status || 207, scripted.rawBody); + } + if (scripted.status && scripted.status !== 207) { + const error = scripted.precondition + ? `` + : ''; + return send(response, scripted.status, error); + } + const events = (scripted.events || []).map(event => { + const href = event.rawHref ?? absoluteHref(event.href); + if (event.status === 404) return removedResponse(href); + return propResponse( + href, + `${xmlEscape(event.etag)}`, + ); + }); + if (scripted.truncated) { + counters.requestUri507++; + events.push(`${BOOK_PATH} +HTTP/1.1 507 Insufficient Storage`); + } + return send(response, 207, multistatus( + `${events.join('\n')}${xmlEscape(scripted.nextToken)}`, + )); + } + + if (body.includes('([\s\S]*?)<\/D:href>/g)] + .map(match => absoluteHref(unescapeXml(match[1]))); + counters.multigetSizes.push(hrefs.length); + const scripted = multigetResponses.shift(); + if (scripted?.status && scripted.status !== 207) { + return send(response, scripted.status, scripted.rawBody || ''); + } + if (scripted && Object.hasOwn(scripted, 'rawBody')) { + return send(response, scripted.status || 207, scripted.rawBody); + } + const responses = hrefs.map(href => { + const contact = contacts.get(href); + return contact ? cardResponse(contact) : removedResponse(href); + }); + return send(response, 207, multistatus(responses.join('\n'))); + } + + if (body.includes('/g)?.length || 0; + counters.snapshotFilters.push(filterCount); + if (filterCount !== 1) { + return send(response, 400, 'snapshot query requires one CardDAV filter'); + } + const responses = [...contacts.values()] + .sort((a, b) => a.href.localeCompare(b.href)) + .map(cardResponse); + return send(response, 207, multistatus(responses.join('\n'))); + } + } + + return send(response, 404); + } catch (error) { + return send(response, 500, `${xmlEscape(error.message)}`); + } + }); + + return { + counters, + requests, + get serverUrl() { return `${origin}/`; }, + href(name) { return absoluteHref(name); }, + putContact(href, etag, vcard) { + const absolute = absoluteHref(href); + contacts.set(absolute, { href: absolute, etag, vcard }); + }, + deleteContact(href) { + contacts.delete(absoluteHref(href)); + }, + reset() { + counters.requests = 0; + counters.propfind = 0; + counters.sync = 0; + counters.multiget = 0; + counters.addressbookQuery = 0; + counters.fetch = 0; + counters.create = 0; + counters.update = 0; + counters.delete = 0; + counters.requestUri507 = 0; + counters.snapshotFilters.length = 0; + counters.syncTokens.length = 0; + counters.multigetSizes.length = 0; + requests.length = 0; + syncResponses.clear(); + multigetResponses.length = 0; + discoveryResponses.length = 0; + writeResponses.clear(); + redirects.clear(); + }, + queueSync, + queueDiscovery(scriptedResponse) { + discoveryResponses.push(scriptedResponse); + }, + queueRedirect(method, path, location, status = 301) { + const key = `${method} ${path}`; + const queue = redirects.get(key) || []; + queue.push({ location, status }); + redirects.set(key, queue); + }, + queueWrite(method, scriptedResponse) { + const queue = writeResponses.get(method) || []; + queue.push(scriptedResponse); + writeResponses.set(method, queue); + }, + queueMultiget(scriptedResponse) { + multigetResponses.push(scriptedResponse); + }, + async listen() { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + origin = `http://127.0.0.1:${server.address().port}`; + }, + async close() { + if (!server.listening) return; + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => ( + error ? reject(error) : resolve() + ))); + }, + }; +} diff --git a/backend/src/services/carddavMappingState.js b/backend/src/services/carddavMappingState.js new file mode 100644 index 00000000..f50e07c6 --- /dev/null +++ b/backend/src/services/carddavMappingState.js @@ -0,0 +1,508 @@ +export function typedError(message, code, details = {}) { + return Object.assign(new Error(message), { code }, details); +} + +export async function rotateBookToken(client, userId, addressBookId) { + const result = await client.query( + `UPDATE address_books + SET sync_token = gen_random_uuid()::text, updated_at = NOW() + WHERE id = $1 AND user_id = $2`, + [addressBookId, userId], + ); + return result.rowCount; +} + +export const UNKNOWN_CARDDAV_CAPABILITIES = Object.freeze({ + create: 'unknown', + update: 'unknown', + delete: 'unknown', +}); + +export function normalizeCarddavCapabilities(capabilities) { + return { ...UNKNOWN_CARDDAV_CAPABILITIES, ...(capabilities || {}) }; +} + +export async function lockCarddavIntegration(client, userId, { requireServerUrl = false } = {}) { + const serverUrlFilter = requireServerUrl + ? "AND NULLIF(config->>'serverUrl', '') IS NOT NULL" + : ''; + const { rows: [integration] } = await client.query( + `SELECT id, config + FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + ${serverUrlFilter} + FOR UPDATE`, + [userId], + ); + return integration || null; +} + +export function assertConflictSnapshotsWithinLimit({ + localVCard, + remoteVCard, + localTombstone = false, + remoteTombstone = false, +}) { + const bytes = (localTombstone ? 0 : Buffer.byteLength(localVCard, 'utf8')) + + (remoteTombstone ? 0 : Buffer.byteLength(remoteVCard, 'utf8')); + if (bytes > 2 * 1024 * 1024) { + throw typedError( + 'CardDAV conflict snapshots exceed 2 MiB', + 'ERR_CARDDAV_CONFLICT_TOO_LARGE', + ); + } +} + +function requireExpectedRevision(change) { + if (Object.hasOwn(change, 'expectedMappingRevision') + && change.expectedMappingRevision !== undefined) return; + throw typedError( + 'An expected CardDAV mapping revision is required', + 'ERR_CARDDAV_MAPPING_REVISION_REQUIRED', + ); +} + +function staleResult(change) { + return { + ok: false, + stale: true, + code: 'ERR_CARDDAV_MAPPING_STALE', + addressBookId: change.addressBookId, + href: change.href, + expectedMappingRevision: String(change.expectedMappingRevision), + }; +} + +export async function lockCarddavMapping(client, { userId, addressBookId, href }) { + const { rows: [mapping] } = await client.query( + `SELECT mapping.*, contact.address_book_id AS local_address_book_id, + contact.id AS contact_id + FROM carddav_remote_objects mapping + JOIN contacts contact ON contact.id = mapping.local_contact_id + WHERE mapping.address_book_id = $1 AND mapping.href = $2 + AND contact.user_id = $3 + FOR UPDATE OF mapping, contact`, + [addressBookId, href, userId], + ); + return mapping || null; +} + +export async function persistPendingMutationIntent(client, change) { + requireExpectedRevision(change); + if (change.operation !== 'update' && change.operation !== 'delete') { + throw typedError('Invalid pending CardDAV operation', 'ERR_CARDDAV_PENDING_OPERATION'); + } + if (!change.pendingLocalHash + || (change.operation === 'update' + && (!change.pendingVCard || !change.pendingRemoteSemanticHash)) + || (change.operation === 'delete' + && (change.pendingVCard != null || change.pendingRemoteSemanticHash != null))) { + throw typedError('Invalid pending CardDAV intent', 'ERR_CARDDAV_PENDING_INTENT'); + } + if (change.pendingVCard + && Buffer.byteLength(change.pendingVCard, 'utf8') > 1024 * 1024) { + throw typedError( + 'Pending CardDAV vCard exceeds 1 MiB', + 'ERR_CARDDAV_PENDING_INTENT_TOO_LARGE', + ); + } + const mapping = await lockCarddavMapping(client, change); + if (!mapping + || String(mapping.mapping_revision) !== String(change.expectedMappingRevision)) { + return staleResult(change); + } + if (mapping.pending_operation) { + throw typedError( + 'A CardDAV mutation is already awaiting confirmation', + 'ERR_CARDDAV_PENDING_INTENT', + { operation: mapping.pending_operation }, + ); + } + const result = await client.query( + `UPDATE carddav_remote_objects SET + mapping_status = 'pending_push', + pending_operation = $4, pending_vcard = $5, + pending_local_hash = $6, pending_remote_semantic_hash = $7, + pending_started_at = NOW(), + mapping_revision = mapping_revision + 1, + updated_at = NOW() + WHERE address_book_id = $1 AND href = $2 + AND mapping_revision = $3::bigint + AND pending_operation IS NULL + RETURNING mapping_revision::text, pending_started_at::text`, + [ + change.addressBookId, + change.href, + String(change.expectedMappingRevision), + change.operation, + change.pendingVCard ?? null, + change.pendingLocalHash, + change.pendingRemoteSemanticHash ?? null, + ], + ); + if (result.rowCount !== 1) return staleResult(change); + return { + ok: true, + mappingRevision: String( + result.rows[0]?.mapping_revision + ?? (BigInt(change.expectedMappingRevision) + 1n), + ), + pendingStartedAt: result.rows[0]?.pending_started_at, + }; +} + +export async function restorePendingMutationIntent(client, change) { + requireExpectedRevision(change); + const result = await client.query( + `UPDATE carddav_remote_objects SET + mapping_status = $8, + pending_operation = NULL, pending_vcard = NULL, + pending_local_hash = NULL, pending_remote_semantic_hash = NULL, + pending_started_at = NULL, + mapping_revision = $9::bigint, + updated_at = $10 + WHERE address_book_id = $1 AND href = $2 + AND mapping_revision = $3::bigint + AND mapping_status = 'pending_push' + AND pending_operation = $4 + AND pending_vcard IS NOT DISTINCT FROM $5 + AND pending_local_hash IS NOT DISTINCT FROM $6 + AND pending_remote_semantic_hash IS NOT DISTINCT FROM $7 + AND pending_started_at = $11::timestamptz + RETURNING mapping_revision::text`, + [ + change.addressBookId, + change.href, + String(change.expectedMappingRevision), + change.operation, + change.pendingVCard ?? null, + change.pendingLocalHash, + change.pendingRemoteSemanticHash ?? null, + change.previousMappingStatus, + String(change.previousMappingRevision), + change.previousUpdatedAt, + change.pendingStartedAt, + ], + ); + if (result.rowCount !== 1) return staleResult(change); + return { + ok: true, + mappingRevision: String( + result.rows[0]?.mapping_revision ?? change.previousMappingRevision, + ), + }; +} + +async function applyConfirmedRemoteContactState(client, change, clearUnresolvedConflict) { + requireExpectedRevision(change); + const mappingStatus = change.mappingStatus ?? 'synced'; + if (mappingStatus !== 'synced' && mappingStatus !== 'pending_push') { + throw typedError('Invalid confirmed CardDAV mapping status', 'ERR_CARDDAV_MAPPING_STATUS'); + } + let result; + if (change.expectedMappingRevision === null) { + result = await client.query( + `INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, + local_contact_id, mapping_status, vcard_version, + remote_semantic_hash, local_contact_hash, last_synced_at + ) VALUES ($1,$2,$3,$4,$5,$6,'synced',$7,$8,$9,NOW()) + ON CONFLICT (address_book_id, href) DO NOTHING + RETURNING mapping_revision::text`, + [ + change.addressBookId, + change.href, + change.remoteEtag ?? null, + change.vcard, + change.primaryEmail ?? null, + change.localContactId, + change.vcardVersion, + change.remoteSemanticHash, + change.localContactHash, + ], + ); + } else { + const clearPendingIntent = change.supportsPendingIntent === false + || change.preservePendingIntent === true + ? '' + : `, pending_operation = NULL, + pending_vcard = NULL, pending_local_hash = NULL, + pending_remote_semantic_hash = NULL, pending_started_at = NULL`; + const clearLegacyProjection = change.clearLegacyProjection === true + ? ', legacy_projection = NULL' + : ''; + result = await client.query( + `UPDATE carddav_remote_objects SET + href = $1, remote_etag = $2, vcard = $3, primary_email = $4, + local_contact_id = $5, mapping_status = '${mappingStatus}', vcard_version = $6, + remote_semantic_hash = $7, local_contact_hash = $8, + mapping_revision = mapping_revision + 1, + last_synced_at = NOW(), last_push_error_code = NULL, + last_push_error_at = NULL${clearPendingIntent}${clearLegacyProjection}, + updated_at = NOW() + WHERE address_book_id = $9 AND href = $1 + AND mapping_revision = $10::bigint + RETURNING mapping_revision::text`, + [ + change.href, + change.remoteEtag ?? null, + change.vcard, + change.primaryEmail ?? null, + change.localContactId, + change.vcardVersion, + change.remoteSemanticHash, + change.localContactHash, + change.addressBookId, + String(change.expectedMappingRevision), + ], + ); + } + if (result.rowCount !== 1) return staleResult(change); + const revision = result.rows[0]?.mapping_revision + ?? (change.expectedMappingRevision === null + ? '0' + : String(BigInt(change.expectedMappingRevision) + 1n)); + if (clearUnresolvedConflict) { + await client.query( + `DELETE FROM carddav_conflicts + WHERE address_book_id = $1 AND href = $2 AND status = 'unresolved'`, + [change.addressBookId, change.href], + ); + } + return { ok: true, mappingRevision: String(revision) }; +} + +export async function applyConfirmedRemoteContact(client, change) { + return applyConfirmedRemoteContactState(client, change, true); +} + +async function applyRemoteTombstoneState(client, change, clearUnresolvedConflict) { + requireExpectedRevision(change); + const result = await client.query( + `DELETE FROM carddav_remote_objects + WHERE address_book_id = $1 AND href = $2 + AND mapping_revision = $3::bigint + RETURNING mapping_revision::text`, + [change.addressBookId, change.href, String(change.expectedMappingRevision)], + ); + if (result.rowCount !== 1) return staleResult(change); + if (clearUnresolvedConflict) { + await client.query( + `DELETE FROM carddav_conflicts + WHERE address_book_id = $1 AND href = $2 AND status = 'unresolved'`, + [change.addressBookId, change.href], + ); + } + return { + ok: true, + mappingRevision: String(result.rows[0]?.mapping_revision ?? change.expectedMappingRevision), + }; +} + +export async function applyRemoteTombstone(client, change) { + return applyRemoteTombstoneState(client, change, true); +} + +export async function resolveCarddavConflict(client, change) { + requireExpectedRevision(change); + const mapping = change.remoteTombstone + ? await applyRemoteTombstoneState(client, change, false) + : await applyConfirmedRemoteContactState(client, change, false); + if (!mapping.ok) return mapping; + const result = await client.query( + `UPDATE carddav_conflicts + SET status = 'resolved', resolution = $2, resolved_by = $3, + resolved_at = NOW(), updated_at = NOW() + WHERE id = $1 AND status = 'unresolved' + AND address_book_id = $4 AND href = $5 AND user_id = $3 + RETURNING *`, + [ + change.conflictId, + change.resolution, + change.userId, + change.addressBookId, + change.href, + ], + ); + if (result.rowCount !== 1) return staleResult(change); + return { ...mapping, conflict: result.rows[0] }; +} + +export async function refreshUnresolvedConflict(client, change) { + requireExpectedRevision(change); + const preserveLocalSnapshot = change.preserveLocalSnapshot === true; + let localVCard = change.localVCard ?? null; + let localTombstone = change.localTombstone ?? false; + if (preserveLocalSnapshot) { + const { rows: [existingConflict] } = await client.query( + `SELECT local_vcard, local_tombstone + FROM carddav_conflicts + WHERE address_book_id = $1 AND href = $2 AND status = 'unresolved'`, + [change.addressBookId, change.href], + ); + if (!existingConflict) { + throw typedError( + 'The unresolved CardDAV conflict local snapshot is missing', + 'ERR_CARDDAV_CONFLICT_MISSING', + ); + } + localVCard = existingConflict.local_vcard; + localTombstone = existingConflict.local_tombstone; + } + assertConflictSnapshotsWithinLimit({ + localVCard, + remoteVCard: change.remoteVCard, + localTombstone, + remoteTombstone: change.remoteTombstone, + }); + const localSnapshotUpdate = preserveLocalSnapshot + ? '' + : ` + local_vcard = EXCLUDED.local_vcard, + local_tombstone = EXCLUDED.local_tombstone,`; + const clearPendingIntent = change.supportsPendingIntent === false + ? '' + : `pending_operation = NULL, pending_vcard = NULL, + pending_local_hash = NULL, pending_remote_semantic_hash = NULL, + pending_started_at = NULL, + `; + const mapping = await client.query( + `UPDATE carddav_remote_objects SET + mapping_status = 'conflict', + ${clearPendingIntent}mapping_revision = mapping_revision + 1, + updated_at = NOW() + WHERE address_book_id = $1 AND href = $2 + AND mapping_revision = $3::bigint + RETURNING mapping_revision::text`, + [ + change.addressBookId, + change.href, + String(change.expectedMappingRevision), + ], + ); + if (mapping.rowCount !== 1) return staleResult(change); + const revision = mapping.rows[0]?.mapping_revision + ?? String(BigInt(change.expectedMappingRevision) + 1n); + const { rows: [conflict] } = await client.query( + `INSERT INTO carddav_conflicts ( + address_book_id, href, user_id, base_local_hash, remote_etag, + local_vcard, remote_vcard, local_tombstone, remote_tombstone + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (address_book_id, href) WHERE status = 'unresolved' + DO UPDATE SET + base_local_hash = EXCLUDED.base_local_hash, + remote_etag = EXCLUDED.remote_etag,${localSnapshotUpdate} + remote_vcard = EXCLUDED.remote_vcard, + remote_tombstone = EXCLUDED.remote_tombstone, + updated_at = NOW() + RETURNING id, status`, + [ + change.addressBookId, + change.href, + change.userId, + change.baseLocalHash ?? null, + change.remoteEtag ?? null, + localVCard, + change.remoteVCard ?? null, + localTombstone, + change.remoteTombstone ?? false, + ], + ); + return { ok: true, mappingRevision: String(revision), conflict }; +} + +export async function persistDiscoveredBook(client, book) { + const capabilities = normalizeCarddavCapabilities(book.capabilities); + const displayName = book.displayName || 'CardDAV'; + const capabilityUpdate = book.preserveCapabilities === true + ? '' + : `, + remote_create_capability = EXCLUDED.remote_create_capability, + remote_update_capability = EXCLUDED.remote_update_capability, + remote_delete_capability = EXCLUDED.remote_delete_capability`; + for (let attempt = 0; attempt < 20; attempt++) { + const name = attempt === 0 ? displayName : `${displayName} (${attempt + 1})`; + await client.query('SAVEPOINT carddav_book_name'); + try { + const { rows: [stored] } = await client.query( + `INSERT INTO address_books ( + user_id, name, source, external_url, + remote_create_capability, remote_update_capability, remote_delete_capability + ) VALUES ($1,$2,'carddav',$3,$4,$5,$6) + ON CONFLICT (user_id, external_url) + WHERE source = 'carddav' AND external_url IS NOT NULL + DO UPDATE SET + external_url = EXCLUDED.external_url${capabilityUpdate}, + updated_at = NOW() + RETURNING id, external_url, remote_sync_token, + remote_sync_revision::text, sync_token, + remote_projection_fingerprint`, + [ + book.userId, + name, + book.url, + capabilities.create, + capabilities.update, + capabilities.delete, + ], + ); + await client.query('RELEASE SAVEPOINT carddav_book_name'); + return stored; + } catch (error) { + if (error.code !== '23505') throw error; + await client.query('ROLLBACK TO SAVEPOINT carddav_book_name'); + await client.query('RELEASE SAVEPOINT carddav_book_name'); + } + } + throw new Error(`Could not create a local address book for "${displayName}"`); +} + +const CAPABILITY_COLUMNS = { + create: 'remote_create_capability', + update: 'remote_update_capability', + delete: 'remote_delete_capability', +}; + +export async function persistDeniedBookCapability(client, { + userId, + addressBookId, + capability, +}) { + const column = CAPABILITY_COLUMNS[capability]; + if (!column) { + throw typedError('Invalid CardDAV book capability', 'ERR_CARDDAV_BOOK_CAPABILITY'); + } + return client.query( + `UPDATE address_books + SET ${column} = 'denied', updated_at = NOW() + WHERE id = $1 AND user_id = $2`, + [addressBookId, userId], + ); +} + +export async function advanceDiscoveredBookState(client, state) { + const capabilities = normalizeCarddavCapabilities(state.capabilities); + return client.query(` + UPDATE address_books SET + external_url = COALESCE($6, external_url), + remote_sync_token = $2, + remote_sync_capability = $3, + remote_sync_revision = remote_sync_revision + 1, + remote_projection_fingerprint = $4, + remote_create_capability = $7, + remote_update_capability = $8, + remote_delete_capability = $9, + updated_at = NOW() + WHERE id = $1 AND remote_sync_revision = $5::bigint + `, [ + state.addressBookId, + state.remoteSyncToken, + state.remoteSyncCapability, + state.remoteProjectionFingerprint, + state.expectedRemoteRevision, + state.canonicalUrl, + capabilities.create, + capabilities.update, + capabilities.delete, + ]); +} diff --git a/backend/src/services/carddavMappingState.test.js b/backend/src/services/carddavMappingState.test.js new file mode 100644 index 00000000..7c67246e --- /dev/null +++ b/backend/src/services/carddavMappingState.test.js @@ -0,0 +1,562 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + advanceDiscoveredBookState, + applyConfirmedRemoteContact, + applyRemoteTombstone, + lockCarddavMapping, + persistDiscoveredBook, + persistPendingMutationIntent, + persistDeniedBookCapability, + refreshUnresolvedConflict, +} from './carddavMappingState.js'; +import * as mappingState from './carddavMappingState.js'; + +const USER_ID = '00000000-0000-4000-8000-000000000001'; +const BOOK_ID = '00000000-0000-4000-8000-000000000002'; +const CONTACT_ID = '00000000-0000-4000-8000-000000000003'; +const CONFLICT_ID = '00000000-0000-4000-8000-000000000004'; +const HREF = 'https://dav.example.test/addressbooks/default/contact.vcf'; + +function change(overrides = {}) { + return { + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '7', + remoteEtag: '"remote-2"', + vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:contact\r\nFN:Remote\r\nEND:VCARD\r\n', + primaryEmail: 'remote@example.test', + localContactId: CONTACT_ID, + vcardVersion: '3.0', + remoteSemanticHash: 'remote-hash-2', + localContactHash: 'local-hash-2', + ...overrides, + }; +} + +describe('lockCarddavMapping', () => { + it('locks the mapping and linked contact for the owned identity', async () => { + const mapping = { href: HREF, mapping_revision: '7', contact_id: CONTACT_ID }; + const client = { query: vi.fn(async () => ({ rows: [mapping] })) }; + await expect(lockCarddavMapping(client, { + userId: USER_ID, addressBookId: BOOK_ID, href: HREF, + })).resolves.toEqual(mapping); + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching(/FOR UPDATE OF mapping, contact/), + [BOOK_ID, HREF, USER_ID], + ); + }); +}); + +describe('mapping revision compare-and-commit', () => { + it('persists one bounded update intent while advancing the locked mapping revision', async () => { + const pendingStartedAt = '2026-07-12 12:00:00.123456+00'; + const client = { query: vi.fn(async sql => { + if (sql.includes('FOR UPDATE OF mapping, contact')) { + return { rows: [{ + ...change(), + mapping_revision: '7', + pending_operation: null, + }] }; + } + return { rows: [{ mapping_revision: '8', pending_started_at: pendingStartedAt }], rowCount: 1 }; + }) }; + + await expect(persistPendingMutationIntent(client, { + userId: USER_ID, + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '7', + operation: 'update', + pendingVCard: change().vcard, + pendingLocalHash: 'local-hash-before', + pendingRemoteSemanticHash: 'remote-hash-2', + })).resolves.toEqual({ ok: true, mappingRevision: '8', pendingStartedAt }); + + const update = client.query.mock.calls.find(([sql]) => ( + sql.includes('UPDATE carddav_remote_objects') + )); + expect(update[0]).toMatch(/pending_operation = \$4[\s\S]+pending_started_at = NOW\(\)/); + expect(update[0]).toContain("mapping_status = 'pending_push'"); + expect(update[0]).toContain('mapping_revision = mapping_revision + 1'); + expect(update[1]).toEqual([ + BOOK_ID, + HREF, + '7', + 'update', + change().vcard, + 'local-hash-before', + 'remote-hash-2', + ]); + }); + + it('rejects a pending update vCard one byte over 1 MiB before locking the mapping', async () => { + const client = { query: vi.fn() }; + const oversized = `BEGIN:VCARD\r\n${'X'.repeat(1024 * 1024)}\r\nEND:VCARD\r\n`; + expect(Buffer.byteLength(oversized, 'utf8')).toBeGreaterThan(1024 * 1024); + + await expect(persistPendingMutationIntent(client, { + userId: USER_ID, + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '7', + operation: 'update', + pendingVCard: oversized, + pendingLocalHash: 'local-hash-before', + pendingRemoteSemanticHash: 'remote-hash-2', + })).rejects.toMatchObject({ code: 'ERR_CARDDAV_PENDING_INTENT_TOO_LARGE' }); + // Rejected by the size gate before any lock query touches the database. + expect(client.query).not.toHaveBeenCalled(); + }); + + it('admits a pending update vCard at exactly 1 MiB past the size gate to the mapping lock', async () => { + const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }; + const frame = Buffer.byteLength('BEGIN:VCARD\r\n\r\nEND:VCARD\r\n', 'utf8'); + const exact = `BEGIN:VCARD\r\n${'X'.repeat(1024 * 1024 - frame)}\r\nEND:VCARD\r\n`; + expect(Buffer.byteLength(exact, 'utf8')).toBe(1024 * 1024); + + // Exactly at the limit passes the size gate and reaches the mapping lock, which + // reports the mapping stale here (rows: []) rather than raising the size error. + await expect(persistPendingMutationIntent(client, { + userId: USER_ID, + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '7', + operation: 'update', + pendingVCard: exact, + pendingLocalHash: 'local-hash-before', + pendingRemoteSemanticHash: 'remote-hash-2', + })).resolves.toMatchObject({ ok: false }); + expect(client.query).toHaveBeenCalled(); + }); + + it('rejects a second intent while the locked mapping already has one', async () => { + const client = { query: vi.fn(async () => ({ rows: [{ + mapping_revision: '7', + pending_operation: 'delete', + pending_started_at: new Date('2026-07-01T00:00:00.000Z'), + }] })) }; + + await expect(persistPendingMutationIntent(client, { + userId: USER_ID, + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '7', + operation: 'update', + pendingVCard: change().vcard, + pendingLocalHash: 'local-hash-before', + pendingRemoteSemanticHash: 'remote-hash-2', + })).rejects.toMatchObject({ code: 'ERR_CARDDAV_PENDING_INTENT' }); + + expect(client.query).toHaveBeenCalledOnce(); + }); + + it('restores only the matching pending intent to its exact confirmed mapping state', async () => { + const previousUpdatedAt = new Date('2026-07-12T11:59:00.000Z'); + const pendingStartedAt = '2026-07-12 12:00:00.123456+00'; + const client = { query: vi.fn(async () => ({ + rows: [{ mapping_revision: '7' }], + rowCount: 1, + })) }; + + await expect(mappingState.restorePendingMutationIntent(client, { + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '8', + operation: 'update', + pendingVCard: change().vcard, + pendingLocalHash: 'local-hash-before', + pendingRemoteSemanticHash: 'remote-hash-2', + pendingStartedAt, + previousMappingStatus: 'synced', + previousMappingRevision: '7', + previousUpdatedAt, + })).resolves.toEqual({ ok: true, mappingRevision: '7' }); + + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /UPDATE carddav_remote_objects[\s\S]+mapping_status = \$8[\s\S]+mapping_revision = \$9::bigint[\s\S]+updated_at = \$10[\s\S]+mapping_revision = \$3::bigint[\s\S]+pending_operation = \$4[\s\S]+pending_vcard IS NOT DISTINCT FROM \$5[\s\S]+pending_local_hash IS NOT DISTINCT FROM \$6[\s\S]+pending_remote_semantic_hash IS NOT DISTINCT FROM \$7[\s\S]+pending_started_at = \$11::timestamptz/, + ), + [ + BOOK_ID, + HREF, + '8', + 'update', + change().vcard, + 'local-hash-before', + 'remote-hash-2', + 'synced', + '7', + previousUpdatedAt, + pendingStartedAt, + ], + ); + }); + + it('reports a stale rollback fence without clobbering an intervening mapping change', async () => { + const pendingStartedAt = '2026-07-12 12:00:00.123456+00'; + const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }; + + await expect(mappingState.restorePendingMutationIntent(client, { + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '8', + operation: 'delete', + pendingVCard: null, + pendingLocalHash: 'local-hash-before', + pendingRemoteSemanticHash: null, + pendingStartedAt, + previousMappingStatus: 'pending_push', + previousMappingRevision: '7', + previousUpdatedAt: new Date('2026-07-12T11:59:00.000Z'), + })).resolves.toEqual({ + ok: false, + stale: true, + code: 'ERR_CARDDAV_MAPPING_STALE', + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '8', + }); + + expect(client.query).toHaveBeenCalledOnce(); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining('pending_started_at = $11::timestamptz'), + expect.arrayContaining([pendingStartedAt]), + ); + }); + + it('persists a confirmed remote snapshot and returns its incremented revision', async () => { + const client = { query: vi.fn(async sql => ( + sql.includes('DELETE FROM carddav_conflicts') + ? { rows: [], rowCount: 0 } + : { rows: [{ mapping_revision: '8' }], rowCount: 1 } + )) }; + await expect(applyConfirmedRemoteContact(client, change())).resolves.toEqual({ + ok: true, mappingRevision: '8', + }); + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /UPDATE carddav_remote_objects[\s\S]+mapping_status = 'synced'[\s\S]+mapping_revision = mapping_revision \+ 1[\s\S]+mapping_revision = \$10::bigint[\s\S]+RETURNING mapping_revision::text/, + ), + [HREF, '"remote-2"', change().vcard, 'remote@example.test', CONTACT_ID, + '3.0', 'remote-hash-2', 'local-hash-2', BOOK_ID, '7'], + ); + }); + + it('retains a local-only change as pending push while confirming harmless remote drift', async () => { + const client = { query: vi.fn(async sql => ( + sql.includes('DELETE FROM carddav_conflicts') + ? { rows: [], rowCount: 0 } + : { rows: [{ mapping_revision: '8' }], rowCount: 1 } + )) }; + + await applyConfirmedRemoteContact(client, change({ + mappingStatus: 'pending_push', + localContactHash: 'confirmed-local-hash', + supportsPendingIntent: true, + preservePendingIntent: true, + })); + + const update = client.query.mock.calls.find(([sql]) => ( + sql.includes('UPDATE carddav_remote_objects') + )); + expect(update[0]).toContain("mapping_status = 'pending_push'"); + expect(update[1]).toContain('confirmed-local-hash'); + expect(update[0]).not.toContain('pending_operation = NULL'); + }); + + it('clears an unresolved conflict after a public remote-tombstone transition', async () => { + const client = { query: vi.fn(async sql => ( + sql.includes('DELETE FROM carddav_remote_objects') + ? { rows: [{ mapping_revision: '7' }], rowCount: 1 } + : { rows: [], rowCount: 1 } + )) }; + + await expect(applyRemoteTombstone(client, { + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '7', + })).resolves.toMatchObject({ ok: true }); + + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /DELETE FROM carddav_conflicts[\s\S]+status = 'unresolved'/, + ), + [BOOK_ID, HREF], + ); + }); + + it.each([ + ['confirmed contact', applyConfirmedRemoteContact, change()], + ['remote tombstone', applyRemoteTombstone, { + addressBookId: BOOK_ID, href: HREF, expectedMappingRevision: '7', + }], + ])('returns typed stale state for a missed %s revision', async (_case, apply, input) => { + const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })) }; + await expect(apply(client, input)).resolves.toEqual({ + ok: false, + stale: true, + code: 'ERR_CARDDAV_MAPPING_STALE', + addressBookId: BOOK_ID, + href: HREF, + expectedMappingRevision: '7', + }); + }); + + it.each([ + ['confirmed contact', applyConfirmedRemoteContact, change({ expectedMappingRevision: undefined })], + ['remote tombstone', applyRemoteTombstone, { addressBookId: BOOK_ID, href: HREF }], + ['conflict refresh', refreshUnresolvedConflict, change({ expectedMappingRevision: undefined })], + ])('requires an expected revision for every %s write', async (_case, apply, input) => { + const client = { query: vi.fn() }; + await expect(apply(client, input)).rejects.toMatchObject({ + code: 'ERR_CARDDAV_MAPPING_REVISION_REQUIRED', + }); + expect(client.query).not.toHaveBeenCalled(); + }); + + it.each([ + ['confirmed contact', false], + ['remote tombstone', true], + ])('owns the conflict transition together with the %s mapping CAS', async ( + _case, + remoteTombstone, + ) => { + const resolved = { + id: CONFLICT_ID, + status: 'resolved', + resolution: 'keep-carddav', + }; + const client = { query: vi.fn(async sql => { + if (sql.includes('UPDATE carddav_conflicts')) { + return { rows: [resolved], rowCount: 1 }; + } + if (sql.includes('UPDATE carddav_remote_objects')) { + return { rows: [{ mapping_revision: '8' }], rowCount: 1 }; + } + if (sql.includes('DELETE FROM carddav_remote_objects')) { + return { rows: [{ mapping_revision: '7' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }) }; + + expect(mappingState.resolveCarddavConflict).toBeTypeOf('function'); + await expect(mappingState.resolveCarddavConflict(client, change({ + conflictId: CONFLICT_ID, + userId: USER_ID, + resolution: 'keep-carddav', + remoteTombstone, + }))).resolves.toMatchObject({ + ok: true, + conflict: resolved, + mappingRevision: remoteTombstone ? '7' : '8', + }); + + const statements = client.query.mock.calls.map(([sql]) => sql); + const conflictIndex = statements.findIndex(sql => sql.includes('UPDATE carddav_conflicts')); + const mappingIndex = statements.findIndex(sql => ( + sql.includes(`${remoteTombstone ? 'DELETE FROM' : 'UPDATE'} carddav_remote_objects`) + )); + expect(mappingIndex).toBeGreaterThanOrEqual(0); + expect(conflictIndex).toBeGreaterThan(mappingIndex); + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching(/UPDATE carddav_conflicts[\s\S]+status = 'resolved'[\s\S]+status = 'unresolved'/), + [CONFLICT_ID, 'keep-carddav', USER_ID, BOOK_ID, HREF], + ); + }); +}); + +describe('refreshUnresolvedConflict', () => { + it('omits pending-intent columns while refreshing a transitional-schema conflict', async () => { + const client = { query: vi.fn(async sql => { + if (sql.includes('UPDATE carddav_remote_objects')) { + return { rows: [{ mapping_revision: '8' }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_conflicts')) { + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }) }; + + await refreshUnresolvedConflict(client, change({ + userId: USER_ID, + localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n', + remoteVCard: change().vcard, + supportsPendingIntent: false, + })); + + const update = client.query.mock.calls.find(([sql]) => ( + sql.includes('UPDATE carddav_remote_objects') + )); + expect(update[0]).not.toContain('pending_operation'); + }); + + it('refreshes the same conflict while advancing the mapping once', async () => { + const client = { query: vi.fn(async sql => { + if (sql.includes('UPDATE carddav_remote_objects')) { + return { rows: [{ mapping_revision: '8' }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_conflicts')) { + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }) }; + await expect(refreshUnresolvedConflict(client, change({ + userId: USER_ID, + baseLocalHash: 'confirmed-local-hash', + localVCard: 'BEGIN:VCARD\r\nFN:Local\r\nEND:VCARD\r\n', + remoteVCard: change().vcard, + localTombstone: false, + remoteTombstone: false, + }))).resolves.toEqual({ + ok: true, + mappingRevision: '8', + conflict: { id: CONFLICT_ID, status: 'unresolved' }, + }); + const conflictInsert = client.query.mock.calls.find(([sql]) => ( + sql.includes('INSERT INTO carddav_conflicts') + )); + expect(conflictInsert[0]).toContain( + "ON CONFLICT (address_book_id, href) WHERE status = 'unresolved'", + ); + }); + + it('preserves the durable local snapshot while refreshing the remote side', async () => { + const durableLocalVCard = 'BEGIN:VCARD\r\nFN:Durable Local\r\nEND:VCARD\r\n'; + const client = { query: vi.fn(async sql => { + if (sql.includes('SELECT local_vcard, local_tombstone')) { + return { rows: [{ + local_vcard: durableLocalVCard, + local_tombstone: true, + }], rowCount: 1 }; + } + if (sql.includes('UPDATE carddav_remote_objects')) { + return { rows: [{ mapping_revision: '8' }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_conflicts')) { + return { rows: [{ id: CONFLICT_ID, status: 'unresolved' }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }) }; + + await refreshUnresolvedConflict(client, change({ + userId: USER_ID, + baseLocalHash: 'confirmed-local-hash', + preserveLocalSnapshot: true, + localVCard: undefined, + remoteVCard: change().vcard, + localTombstone: undefined, + remoteTombstone: false, + })); + + const conflictInsert = client.query.mock.calls.find(([sql]) => ( + sql.includes('INSERT INTO carddav_conflicts') + )); + expect(conflictInsert[0]).not.toMatch(/local_vcard = EXCLUDED\.local_vcard/); + expect(conflictInsert[0]).not.toMatch(/local_tombstone = EXCLUDED\.local_tombstone/); + expect(conflictInsert[1].slice(5, 9)).toEqual([ + durableLocalVCard, + change().vcard, + true, + false, + ]); + const mappingUpdate = client.query.mock.calls.find(([sql]) => ( + sql.includes('UPDATE carddav_remote_objects') + )); + expect(mappingUpdate[0]).toMatch(/pending_operation = NULL[\s\S]+pending_started_at = NULL/); + }); +}); + +describe('persistDiscoveredBook', () => { + it('persists capabilities without overwriting sync state', async () => { + const stored = { id: BOOK_ID, external_url: 'https://dav.example.test/addressbooks/default/' }; + const client = { query: vi.fn(async () => ({ rows: [stored], rowCount: 1 })) }; + await expect(persistDiscoveredBook(client, { + userId: USER_ID, + url: stored.external_url, + displayName: 'Remote', + capabilities: { create: 'allowed', update: 'denied', delete: 'unknown' }, + })).resolves.toEqual(stored); + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /INSERT INTO address_books[\s\S]+remote_create_capability[\s\S]+ON CONFLICT[\s\S]+remote_create_capability = EXCLUDED.remote_create_capability/, + ), + [USER_ID, 'Remote', stored.external_url, 'allowed', 'denied', 'unknown'], + ); + }); + + it('owns a single denied capability update without changing its siblings', async () => { + const client = { query: vi.fn(async () => ({ rows: [{ id: BOOK_ID }], rowCount: 1 })) }; + + await persistDeniedBookCapability(client, { + userId: USER_ID, + addressBookId: BOOK_ID, + capability: 'update', + }); + + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /UPDATE address_books[\s\S]+remote_update_capability = 'denied'[\s\S]+WHERE id = \$1 AND user_id = \$2/, + ), + [BOOK_ID, USER_ID], + ); + expect(client.query.mock.calls[0][0]).not.toMatch(/remote_(?:create|delete)_capability =/); + }); + + it('owns guarded remote token and observed-capability advancement', async () => { + const client = { query: vi.fn(async () => ({ rows: [], rowCount: 1 })) }; + + await advanceDiscoveredBookState(client, { + addressBookId: BOOK_ID, + expectedRemoteRevision: '4', + canonicalUrl: 'https://dav.example.test/addressbooks/default/', + remoteSyncToken: 'opaque-token-2', + remoteSyncCapability: 'supported', + remoteProjectionFingerprint: 'projection-fingerprint', + capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' }, + }); + + expect(client.query).toHaveBeenCalledWith( + expect.stringMatching( + /UPDATE address_books[\s\S]+remote_sync_token = \$2[\s\S]+remote_create_capability = \$7[\s\S]+remote_sync_revision = \$5::bigint/, + ), + [ + BOOK_ID, + 'opaque-token-2', + 'supported', + 'projection-fingerprint', + '4', + 'https://dav.example.test/addressbooks/default/', + 'allowed', + 'unknown', + 'denied', + ], + ); + }); +}); + +describe('assertConflictSnapshotsWithinLimit', () => { + const HALF = 'x'.repeat(1024 * 1024); + + it('accepts combined local and remote snapshots at exactly 2 MiB', () => { + expect(() => mappingState.assertConflictSnapshotsWithinLimit({ + localVCard: HALF, + remoteVCard: HALF, + })).not.toThrow(); + }); + + it('rejects combined snapshots one byte over 2 MiB', () => { + expect(() => mappingState.assertConflictSnapshotsWithinLimit({ + localVCard: `${HALF}x`, + remoteVCard: HALF, + })).toThrow(/2 MiB/); + }); + + it('excludes a tombstoned side from the combined size', () => { + expect(() => mappingState.assertConflictSnapshotsWithinLimit({ + localVCard: null, + remoteVCard: 'x'.repeat(2 * 1024 * 1024), + localTombstone: true, + })).not.toThrow(); + }); +}); diff --git a/backend/src/services/carddavMigration.db.test.js b/backend/src/services/carddavMigration.db.test.js new file mode 100644 index 00000000..a25b6bbb --- /dev/null +++ b/backend/src/services/carddavMigration.db.test.js @@ -0,0 +1,1129 @@ +import { randomUUID } from 'crypto'; +import { mkdtemp, readdir, rm, symlink, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { runMigrationsWithPool } from './migrations.js'; +import { + applyTestMigrations, + assertMinimumPostgresVersion, + createTestDatabase, + dropTestDatabase, + postgresTestContext, +} from './postgresTestHelpers.js'; + +const { Client, Pool } = pg; +const { databaseUrl, connectionStringFor } = postgresTestContext('CardDAV migration tests'); +const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations'); +const databaseSuffix = `${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`; +const databaseNames = { + upgrade: `carddav_upgrade_${databaseSuffix}`, + fresh: `carddav_fresh_${databaseSuffix}`, + runner: `carddav_runner_${databaseSuffix}`, + failure: `carddav_failure_${databaseSuffix}`, + populatedDisjoint: `carddav_populated_disjoint_${databaseSuffix}`, + populatedCollision: `carddav_populated_collision_${databaseSuffix}`, + populatedFirst: `carddav_populated_first_${databaseSuffix}`, + emptyDuplicates: `carddav_empty_duplicates_${databaseSuffix}`, +}; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +let adminClient; + +async function applyMigrations(client, firstVersion, lastVersion) { + await applyTestMigrations(client, { + migrationsDirectory, + first: firstVersion, + through: lastVersion, + transactionPerMigration: true, + }); +} + +async function createMigrationDirectory(firstVersion, lastVersion) { + const directory = await mkdtemp(join(tmpdir(), 'mailflow-migrations-')); + const filenames = (await readdir(migrationsDirectory)) + .filter(filename => /^\d{4}_.+\.sql$/.test(filename)) + .filter(filename => filename.slice(0, 4) >= firstVersion) + .filter(filename => filename.slice(0, 4) <= lastVersion); + + await Promise.all(filenames.map(filename => symlink( + join(migrationsDirectory, filename), + join(directory, filename), + ))); + return directory; +} + +async function readCardDavRows(client, userId) { + const { rows: books } = await client.query(` + SELECT * + FROM address_books + WHERE user_id = $1 AND source = 'carddav' + ORDER BY id + `, [userId]); + const { rows: contacts } = await client.query(` + SELECT * + FROM contacts + WHERE user_id = $1 + ORDER BY id + `, [userId]); + + return { books, contacts }; +} + +async function withDatabase(name, callback) { + const client = new Client({ connectionString: connectionStringFor(name) }); + await client.connect(); + try { + await callback(client); + } finally { + await client.end(); + } +} + +async function readColumns(client, tableName) { + const { rows } = await client.query(` + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position + `, [tableName]); + + return Object.fromEntries(rows.map(({ column_name, ...column }) => [column_name, column])); +} + +async function readPrimaryKey(client, tableName) { + const { rows } = await client.query(` + SELECT key_column_usage.column_name + FROM information_schema.table_constraints + JOIN information_schema.key_column_usage + USING (constraint_catalog, constraint_schema, constraint_name, table_catalog, table_schema, table_name) + WHERE table_schema = 'public' + AND table_name = $1 + AND constraint_type = 'PRIMARY KEY' + ORDER BY key_column_usage.ordinal_position + `, [tableName]); + + return rows.map(row => row.column_name); +} + +async function readForeignKeys(client, tableName) { + const { rows } = await client.query(` + SELECT source_attribute.attname AS column_name, + target_table.relname AS referenced_table, + target_attribute.attname AS referenced_column, + CASE constraint_row.confdeltype + WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + END AS delete_rule + FROM pg_constraint constraint_row + JOIN pg_class source_table ON source_table.oid = constraint_row.conrelid + JOIN pg_namespace source_schema ON source_schema.oid = source_table.relnamespace + JOIN pg_class target_table ON target_table.oid = constraint_row.confrelid + JOIN LATERAL unnest(constraint_row.conkey, constraint_row.confkey) + WITH ORDINALITY AS key_columns(source_attnum, target_attnum, position) ON true + JOIN pg_attribute source_attribute + ON source_attribute.attrelid = source_table.oid + AND source_attribute.attnum = key_columns.source_attnum + JOIN pg_attribute target_attribute + ON target_attribute.attrelid = target_table.oid + AND target_attribute.attnum = key_columns.target_attnum + WHERE source_schema.nspname = 'public' + AND source_table.relname = $1 + AND constraint_row.contype = 'f' + ORDER BY source_attribute.attname + `, [tableName]); + + return rows; +} + +async function readChecks(client, tableNames) { + const { rows } = await client.query(` + SELECT table_constraints.table_name, check_constraints.check_clause + FROM information_schema.table_constraints + JOIN information_schema.check_constraints + USING (constraint_catalog, constraint_schema, constraint_name) + WHERE table_constraints.table_schema = 'public' + AND table_constraints.table_name = ANY($1::text[]) + AND table_constraints.constraint_type = 'CHECK' + ORDER BY table_constraints.table_name, table_constraints.constraint_name + `, [tableNames]); + + return rows; +} + +async function readIndexDefinitions(client, indexNames) { + const { rows } = await client.query(` + SELECT indexname, indexdef + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = ANY($1::text[]) + ORDER BY indexname + `, [indexNames]); + + return Object.fromEntries(rows.map(({ indexname, indexdef }) => [ + indexname, + indexdef.replace(/^CREATE (UNIQUE )?INDEX \S+ ON \S+ USING btree /, '$1'), + ])); +} + +async function expectIncrementalSchema(client) { + const addressBookColumns = await readColumns(client, 'address_books'); + expect(addressBookColumns).toMatchObject({ + remote_sync_token: { data_type: 'text', is_nullable: 'YES' }, + remote_sync_capability: { data_type: 'text', is_nullable: 'NO' }, + remote_sync_revision: { data_type: 'bigint', is_nullable: 'NO' }, + remote_projection_fingerprint: { data_type: 'text', is_nullable: 'YES' }, + }); + + const remoteObjectColumns = await readColumns(client, 'carddav_remote_objects'); + expect(remoteObjectColumns).toMatchObject({ + address_book_id: { data_type: 'uuid', is_nullable: 'NO' }, + href: { data_type: 'text', is_nullable: 'NO' }, + remote_etag: { data_type: 'text', is_nullable: 'YES' }, + vcard: { data_type: 'text', is_nullable: 'NO' }, + primary_email: { data_type: 'text', is_nullable: 'YES' }, + disposition: { data_type: 'text', is_nullable: 'NO' }, + local_contact_id: { data_type: 'uuid', is_nullable: 'YES' }, + merge_before: { data_type: 'jsonb', is_nullable: 'YES' }, + merge_applied: { data_type: 'jsonb', is_nullable: 'YES' }, + created_at: { data_type: 'timestamp with time zone', is_nullable: 'NO' }, + updated_at: { data_type: 'timestamp with time zone', is_nullable: 'NO' }, + }); + + expect(await readPrimaryKey(client, 'carddav_remote_objects')) + .toEqual(['address_book_id', 'href']); + + expect(await readForeignKeys(client, 'carddav_remote_objects')).toEqual([ + { + column_name: 'address_book_id', + referenced_table: 'address_books', + referenced_column: 'id', + delete_rule: 'CASCADE', + }, + { + column_name: 'local_contact_id', + referenced_table: 'contacts', + referenced_column: 'id', + delete_rule: 'SET NULL', + }, + ]); + + expect(await readChecks(client, ['address_books', 'carddav_remote_objects'])).toEqual( + expect.arrayContaining([ + { + table_name: 'address_books', + check_clause: "((remote_sync_capability = ANY (ARRAY['unknown'::text, 'sync-collection'::text, 'snapshot'::text])))", + }, + { + table_name: 'carddav_remote_objects', + check_clause: "((disposition = ANY (ARRAY['separate'::text, 'merge'::text, 'skip'::text])))", + }, + ]), + ); + + const indexDefinitions = await readIndexDefinitions(client, [ + 'carddav_one_remote_book_idx', + 'carddav_remote_object_contact_idx', + 'carddav_remote_object_email_idx', + 'carddav_one_merge_source_per_contact_idx', + ]); + const definitions = Object.values(indexDefinitions); + expect(definitions).toContainEqual(expect.stringContaining( + 'UNIQUE (user_id, external_url) WHERE', + )); + expect(definitions).toContainEqual(expect.stringContaining( + "WHERE ((disposition = 'merge'::text) AND (local_contact_id IS NOT NULL))", + )); + expect(definitions).toContain( + "UNIQUE (user_id, external_url) WHERE ((source = 'carddav'::text) AND (external_url IS NOT NULL))", + ); + expect(definitions).toContain( + "UNIQUE (local_contact_id) WHERE ((disposition = 'merge'::text) AND (local_contact_id IS NOT NULL))", + ); + expect(definitions).toContain( + '(local_contact_id) WHERE (local_contact_id IS NOT NULL)', + ); + expect(definitions).toContain('(address_book_id, primary_email)'); + + const userId = randomUUID(); + const contactId = randomUUID(); + await client.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-migration-${userId}`], + ); + const { rows: [addressBook] } = await client.query(` + INSERT INTO address_books (user_id, name, source, external_url) + VALUES ($1, 'Remote', 'carddav', $2) + RETURNING id, remote_sync_revision + `, [userId, `https://carddav.example.test/${userId}`]); + expect(addressBook.remote_sync_revision).toBe('0'); + + await client.query(` + INSERT INTO contacts (id, address_book_id, user_id, uid, vcard, display_name) + VALUES ($1, $2, $3, 'indexed-contact', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'Indexed Contact') + `, [contactId, addressBook.id, userId]); + await client.query(` + INSERT INTO carddav_remote_objects ( + address_book_id, href, vcard, primary_email, disposition, local_contact_id + ) VALUES + ($1, '/indexed.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'indexed@example.test', 'separate', $2), + ($1, '/skipped.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n', NULL, 'skip', NULL) + `, [addressBook.id, contactId]); + + await client.query('BEGIN'); + try { + await client.query('SET LOCAL enable_seqscan = off'); + const { rows } = await client.query(` + EXPLAIN (FORMAT JSON) + SELECT href FROM carddav_remote_objects WHERE local_contact_id = $1 + `, [contactId]); + expect(JSON.stringify(rows[0]['QUERY PLAN'])).toContain( + '"Index Name":"carddav_remote_object_contact_idx"', + ); + } finally { + await client.query('ROLLBACK'); + } +} + +async function expectBidirectionalSchema(client) { + const addressBookColumns = await readColumns(client, 'address_books'); + expect(addressBookColumns).toMatchObject({ + remote_create_capability: { + data_type: 'text', + is_nullable: 'NO', + column_default: "'unknown'::text", + }, + remote_update_capability: { + data_type: 'text', + is_nullable: 'NO', + column_default: "'unknown'::text", + }, + remote_delete_capability: { + data_type: 'text', + is_nullable: 'NO', + column_default: "'unknown'::text", + }, + }); + + const contactColumns = await readColumns(client, 'contacts'); + expect(contactColumns.additional_fields).toEqual({ + data_type: 'jsonb', + is_nullable: 'NO', + column_default: "'[]'::jsonb", + }); + + const mappingColumns = await readColumns(client, 'carddav_remote_objects'); + expect(mappingColumns).toEqual({ + address_book_id: { data_type: 'uuid', is_nullable: 'NO', column_default: null }, + href: { data_type: 'text', is_nullable: 'NO', column_default: null }, + remote_etag: { data_type: 'text', is_nullable: 'YES', column_default: null }, + vcard: { data_type: 'text', is_nullable: 'NO', column_default: null }, + primary_email: { data_type: 'text', is_nullable: 'YES', column_default: null }, + disposition: { + data_type: 'text', + is_nullable: 'NO', + column_default: "'separate'::text", + }, + local_contact_id: { data_type: 'uuid', is_nullable: 'YES', column_default: null }, + merge_before: { data_type: 'jsonb', is_nullable: 'YES', column_default: null }, + merge_applied: { data_type: 'jsonb', is_nullable: 'YES', column_default: null }, + created_at: { + data_type: 'timestamp with time zone', + is_nullable: 'NO', + column_default: 'now()', + }, + updated_at: { + data_type: 'timestamp with time zone', + is_nullable: 'NO', + column_default: 'now()', + }, + mapping_status: { + data_type: 'text', + is_nullable: 'NO', + column_default: "'pending_materialization'::text", + }, + vcard_version: { data_type: 'text', is_nullable: 'YES', column_default: null }, + remote_semantic_hash: { data_type: 'text', is_nullable: 'YES', column_default: null }, + local_contact_hash: { data_type: 'text', is_nullable: 'YES', column_default: null }, + mapping_revision: { data_type: 'bigint', is_nullable: 'NO', column_default: '0' }, + last_synced_at: { + data_type: 'timestamp with time zone', + is_nullable: 'YES', + column_default: null, + }, + last_push_error_code: { data_type: 'text', is_nullable: 'YES', column_default: null }, + last_push_error_at: { + data_type: 'timestamp with time zone', + is_nullable: 'YES', + column_default: null, + }, + legacy_projection: { data_type: 'jsonb', is_nullable: 'YES', column_default: null }, + }); + + const conflictColumns = await readColumns(client, 'carddav_conflicts'); + expect(conflictColumns).toEqual({ + id: { data_type: 'uuid', is_nullable: 'NO', column_default: 'gen_random_uuid()' }, + address_book_id: { data_type: 'uuid', is_nullable: 'NO', column_default: null }, + href: { data_type: 'text', is_nullable: 'NO', column_default: null }, + user_id: { data_type: 'uuid', is_nullable: 'NO', column_default: null }, + base_local_hash: { data_type: 'text', is_nullable: 'YES', column_default: null }, + remote_etag: { data_type: 'text', is_nullable: 'YES', column_default: null }, + local_vcard: { data_type: 'text', is_nullable: 'YES', column_default: null }, + remote_vcard: { data_type: 'text', is_nullable: 'YES', column_default: null }, + local_tombstone: { data_type: 'boolean', is_nullable: 'NO', column_default: 'false' }, + remote_tombstone: { data_type: 'boolean', is_nullable: 'NO', column_default: 'false' }, + status: { data_type: 'text', is_nullable: 'NO', column_default: "'unresolved'::text" }, + resolution: { data_type: 'text', is_nullable: 'YES', column_default: null }, + resolved_by: { data_type: 'uuid', is_nullable: 'YES', column_default: null }, + resolved_at: { + data_type: 'timestamp with time zone', + is_nullable: 'YES', + column_default: null, + }, + created_at: { + data_type: 'timestamp with time zone', + is_nullable: 'NO', + column_default: 'now()', + }, + updated_at: { + data_type: 'timestamp with time zone', + is_nullable: 'NO', + column_default: 'now()', + }, + }); + + expect(await readPrimaryKey(client, 'carddav_conflicts')).toEqual(['id']); + expect(await readForeignKeys(client, 'carddav_conflicts')).toEqual([ + { + column_name: 'address_book_id', + referenced_table: 'carddav_remote_objects', + referenced_column: 'address_book_id', + delete_rule: 'CASCADE', + }, + { + column_name: 'href', + referenced_table: 'carddav_remote_objects', + referenced_column: 'href', + delete_rule: 'CASCADE', + }, + { + column_name: 'resolved_by', + referenced_table: 'users', + referenced_column: 'id', + delete_rule: 'SET NULL', + }, + { + column_name: 'user_id', + referenced_table: 'users', + referenced_column: 'id', + delete_rule: 'CASCADE', + }, + ]); + + const checks = await readChecks(client, [ + 'address_books', + 'carddav_remote_objects', + 'carddav_conflicts', + ]); + expect(checks).toEqual(expect.arrayContaining([ + { + table_name: 'address_books', + check_clause: "((remote_create_capability = ANY (ARRAY['unknown'::text, 'allowed'::text, 'denied'::text])))", + }, + { + table_name: 'address_books', + check_clause: "((remote_update_capability = ANY (ARRAY['unknown'::text, 'allowed'::text, 'denied'::text])))", + }, + { + table_name: 'address_books', + check_clause: "((remote_delete_capability = ANY (ARRAY['unknown'::text, 'allowed'::text, 'denied'::text])))", + }, + { + table_name: 'carddav_remote_objects', + check_clause: "((mapping_status = ANY (ARRAY['pending_materialization'::text, 'synced'::text, 'pending_push'::text, 'conflict'::text])))", + }, + { + table_name: 'carddav_remote_objects', + check_clause: '((mapping_revision >= 0))', + }, + { + table_name: 'carddav_remote_objects', + check_clause: "(((vcard_version IS NULL) OR (vcard_version = ANY (ARRAY['3.0'::text, '4.0'::text]))))", + }, + { + table_name: 'carddav_conflicts', + check_clause: "((status = ANY (ARRAY['unresolved'::text, 'resolved'::text])))", + }, + { + table_name: 'carddav_conflicts', + check_clause: "(((resolution IS NULL) OR (resolution = ANY (ARRAY['keep-mailflow'::text, 'keep-carddav'::text]))))", + }, + { + table_name: 'carddav_conflicts', + check_clause: '((local_tombstone OR (local_vcard IS NOT NULL)))', + }, + { + table_name: 'carddav_conflicts', + check_clause: '((remote_tombstone OR (remote_vcard IS NOT NULL)))', + }, + { + table_name: 'carddav_conflicts', + check_clause: "((((status = 'unresolved'::text) AND (resolution IS NULL) AND (resolved_by IS NULL) AND (resolved_at IS NULL)) OR ((status = 'resolved'::text) AND (resolution IS NOT NULL) AND (resolved_at IS NOT NULL))))", + }, + ])); + + const indexDefinitions = await readIndexDefinitions(client, [ + 'carddav_one_active_mapping_per_contact_idx', + 'carddav_one_unresolved_conflict_per_mapping_idx', + ]); + expect(indexDefinitions.carddav_one_active_mapping_per_contact_idx).toBe( + "UNIQUE (local_contact_id) WHERE ((local_contact_id IS NOT NULL) AND (mapping_status <> 'pending_materialization'::text))", + ); + expect(indexDefinitions.carddav_one_unresolved_conflict_per_mapping_idx).toBe( + "UNIQUE (address_book_id, href) WHERE (status = 'unresolved'::text)", + ); +} + +async function expectContractedBidirectionalSchema(client) { + const mappingColumns = await readColumns(client, 'carddav_remote_objects'); + expect(mappingColumns).not.toHaveProperty('disposition'); + expect(mappingColumns).not.toHaveProperty('merge_before'); + expect(mappingColumns).not.toHaveProperty('merge_applied'); + expect(mappingColumns).not.toHaveProperty('legacy_projection'); + expect(mappingColumns).toMatchObject({ + local_contact_id: { data_type: 'uuid', is_nullable: 'YES', column_default: null }, + mapping_status: { + data_type: 'text', + is_nullable: 'NO', + column_default: "'pending_materialization'::text", + }, + remote_semantic_hash: { data_type: 'text', is_nullable: 'YES', column_default: null }, + local_contact_hash: { data_type: 'text', is_nullable: 'YES', column_default: null }, + pending_operation: { data_type: 'text', is_nullable: 'YES', column_default: null }, + pending_vcard: { data_type: 'text', is_nullable: 'YES', column_default: null }, + pending_local_hash: { data_type: 'text', is_nullable: 'YES', column_default: null }, + pending_remote_semantic_hash: { + data_type: 'text', + is_nullable: 'YES', + column_default: null, + }, + pending_started_at: { + data_type: 'timestamp with time zone', + is_nullable: 'YES', + column_default: null, + }, + }); + + const checks = await readChecks(client, ['carddav_remote_objects']); + expect(checks).toEqual(expect.arrayContaining([ + { + table_name: 'carddav_remote_objects', + check_clause: "(((pending_operation IS NULL) OR (pending_operation = ANY (ARRAY['update'::text, 'delete'::text]))))", + }, + { + table_name: 'carddav_remote_objects', + check_clause: "((((pending_operation IS NULL) AND (pending_vcard IS NULL) AND (pending_local_hash IS NULL) AND (pending_remote_semantic_hash IS NULL) AND (pending_started_at IS NULL)) OR ((pending_operation = 'update'::text) AND (pending_vcard IS NOT NULL) AND (pending_local_hash IS NOT NULL) AND (pending_remote_semantic_hash IS NOT NULL) AND (pending_started_at IS NOT NULL)) OR ((pending_operation = 'delete'::text) AND (pending_vcard IS NULL) AND (pending_local_hash IS NOT NULL) AND (pending_remote_semantic_hash IS NULL) AND (pending_started_at IS NOT NULL))))", + }, + ])); + + const indexDefinitions = await readIndexDefinitions(client, [ + 'carddav_one_active_mapping_per_contact_idx', + 'carddav_one_merge_source_per_contact_idx', + ]); + expect(indexDefinitions.carddav_one_active_mapping_per_contact_idx).toBe( + "UNIQUE (local_contact_id) WHERE ((local_contact_id IS NOT NULL) AND (mapping_status <> 'pending_materialization'::text))", + ); + expect(indexDefinitions).not.toHaveProperty('carddav_one_merge_source_per_contact_idx'); +} + +async function expectConflictRetentionSchema(client) { + expect(await readForeignKeys(client, 'carddav_conflicts')).toEqual([ + { + column_name: 'address_book_id', + referenced_table: 'address_books', + referenced_column: 'id', + delete_rule: 'CASCADE', + }, + { + column_name: 'resolved_by', + referenced_table: 'users', + referenced_column: 'id', + delete_rule: 'SET NULL', + }, + { + column_name: 'user_id', + referenced_table: 'users', + referenced_column: 'id', + delete_rule: 'CASCADE', + }, + ]); +} + +it('keeps migration versions unique and orders the CardDAV lifecycle migrations', async () => { + const filenames = (await readdir(migrationsDirectory)) + .filter(filename => /^\d{4}_.+\.sql$/.test(filename)) + .sort(); + const versions = filenames.map(filename => filename.slice(0, 4)); + const duplicateVersions = versions.filter((version, index) => versions.indexOf(version) !== index); + const upstreamIndex = filenames.indexOf('0033_gtd_pets_custom.sql'); + const incrementalIndex = filenames.indexOf('0034_carddav_incremental_sync.sql'); + const expandIndex = filenames.indexOf('0035_carddav_bidirectional_sync.sql'); + const contractIndex = filenames.indexOf('0036_carddav_bidirectional_cleanup.sql'); + const retentionIndex = filenames.indexOf('0037_carddav_conflict_retention.sql'); + + expect(duplicateVersions).toEqual([]); + expect(upstreamIndex).toBeGreaterThanOrEqual(0); + expect(incrementalIndex).toBe(upstreamIndex + 1); + expect(expandIndex).toBe(incrementalIndex + 1); + expect(contractIndex).toBe(expandIndex + 1); + expect(retentionIndex).toBe(contractIndex + 1); +}); + +describe('CardDAV schema migrations', () => { + beforeAll(async () => { + adminClient = new Client({ connectionString: databaseUrl }); + await adminClient.connect(); + + for (const name of Object.values(databaseNames)) { + await createTestDatabase(adminClient, name); + } + }, 120_000); + + afterAll(async () => { + if (!adminClient) return; + + for (const name of Object.values(databaseNames)) { + await dropTestDatabase(adminClient, name); + } + await adminClient.end(); + }, 120_000); + + it('runs the production migration loop idempotently against a fresh database', async () => { + const migrationPool = new Pool({ + connectionString: connectionStringFor(databaseNames.runner), + }); + try { + await runMigrationsWithPool(migrationPool, migrationsDirectory); + await runMigrationsWithPool(migrationPool, migrationsDirectory); + + const versions = await migrationPool.query( + 'SELECT version FROM schema_migrations ORDER BY version', + ); + expect(versions.rows.map(row => row.version)) + .toContain('0037_carddav_conflict_retention'); + expect(new Set(versions.rows.map(row => row.version)).size) + .toBe(versions.rows.length); + } finally { + await migrationPool.end(); + } + }, 120_000); + + it('rolls back a failing production migration and releases its advisory lock', async () => { + const failingDirectory = await mkdtemp(join(tmpdir(), 'mailflow-migrations-')); + const migrationPool = new Pool({ + connectionString: connectionStringFor(databaseNames.failure), + }); + const lockClient = new Client({ + connectionString: connectionStringFor(databaseNames.failure), + }); + await writeFile( + join(failingDirectory, '9000_failing.sql'), + 'CREATE TABLE rollback_probe (id integer);\nSELECT missing_column FROM rollback_probe;\n', + ); + + try { + await expect(runMigrationsWithPool(migrationPool, failingDirectory)).rejects.toThrow(); + + const versions = await migrationPool.query( + 'SELECT version FROM schema_migrations ORDER BY version', + ); + expect(versions.rows.map(row => row.version)).not.toContain('9000_failing'); + const { rows: [probe] } = await migrationPool.query( + "SELECT to_regclass('public.rollback_probe') AS relation", + ); + expect(probe.relation).toBeNull(); + + await lockClient.connect(); + const { rows: [lock] } = await lockClient.query( + 'SELECT pg_try_advisory_lock(7418291834) AS acquired', + ); + expect(lock.acquired).toBe(true); + await lockClient.query('SELECT pg_advisory_unlock(7418291834)'); + } finally { + await lockClient.end().catch(() => {}); + await migrationPool.end(); + await rm(failingDirectory, { recursive: true, force: true }); + } + }, 120_000); + + it.each([ + { + databaseName: databaseNames.populatedDisjoint, + label: 'disjoint contact UIDs', + contactUids: ['disjoint-a', 'disjoint-b'], + }, + { + databaseName: databaseNames.populatedCollision, + label: 'colliding contact UIDs', + contactUids: ['same-uid', 'same-uid'], + }, + ])('aborts 0034 without changing populated duplicates with $label', async ({ + databaseName, + contactUids, + }) => { + const through0033Directory = await createMigrationDirectory('0001', '0033'); + const migration0034Directory = await createMigrationDirectory('0034', '0034'); + const migrationPool = new Pool({ connectionString: connectionStringFor(databaseName) }); + const userId = randomUUID(); + const earlierBookId = '10000000-0000-4000-8000-000000000001'; + const laterBookId = '10000000-0000-4000-8000-000000000002'; + + try { + await runMigrationsWithPool(migrationPool, through0033Directory); + await migrationPool.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-populated-${userId}`], + ); + await migrationPool.query(` + INSERT INTO address_books (id, user_id, name, source, external_url, created_at) + VALUES + ($1, $3, 'Earlier', 'carddav', 'https://dav.example.test/populated', + '2026-01-01T00:00:00Z'), + ($2, $3, 'Later', 'carddav', 'https://dav.example.test/populated', + '2026-01-02T00:00:00Z') + `, [earlierBookId, laterBookId, userId]); + await migrationPool.query(` + INSERT INTO contacts (address_book_id, user_id, uid, display_name) + VALUES + ($1, $3, $4, 'Earlier Contact'), + ($2, $3, $5, 'Later Contact') + `, [earlierBookId, laterBookId, userId, ...contactUids]); + const before = await readCardDavRows(migrationPool, userId); + + await expect(runMigrationsWithPool(migrationPool, migration0034Directory)) + .rejects.toThrow('cannot consolidate populated duplicate CardDAV address books'); + + expect(await readCardDavRows(migrationPool, userId)).toEqual(before); + const { rows: [index] } = await migrationPool.query( + "SELECT to_regclass('public.carddav_one_remote_book_idx') AS relation", + ); + expect(index.relation).toBeNull(); + const { rows: versions } = await migrationPool.query( + "SELECT version FROM schema_migrations WHERE version = '0034_carddav_incremental_sync'", + ); + expect(versions).toEqual([]); + } finally { + await migrationPool.end(); + await rm(through0033Directory, { recursive: true, force: true }); + await rm(migration0034Directory, { recursive: true, force: true }); + } + }, 120_000); + + it('consolidates an empty later duplicate while preserving the populated first book', async () => { + const through0033Directory = await createMigrationDirectory('0001', '0033'); + const migration0034Directory = await createMigrationDirectory('0034', '0034'); + const migrationPool = new Pool({ + connectionString: connectionStringFor(databaseNames.populatedFirst), + }); + const userId = randomUUID(); + const keptBookId = '10000000-0000-4000-8000-000000000001'; + const removedBookId = '10000000-0000-4000-8000-000000000002'; + + try { + await runMigrationsWithPool(migrationPool, through0033Directory); + await migrationPool.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-populated-first-${userId}`], + ); + await migrationPool.query(` + INSERT INTO address_books (id, user_id, name, source, external_url, created_at) + VALUES + ($1, $3, 'Earlier', 'carddav', 'https://dav.example.test/populated-first', + '2026-01-01T00:00:00Z'), + ($2, $3, 'Later', 'carddav', 'https://dav.example.test/populated-first', + '2026-01-02T00:00:00Z') + `, [keptBookId, removedBookId, userId]); + const { rows: [contact] } = await migrationPool.query(` + INSERT INTO contacts (address_book_id, user_id, uid, display_name) + VALUES ($1, $2, 'kept-contact', 'Kept Contact') + RETURNING id + `, [keptBookId, userId]); + + await runMigrationsWithPool(migrationPool, migration0034Directory); + + expect(await readCardDavRows(migrationPool, userId)).toMatchObject({ + books: [{ id: keptBookId }], + contacts: [{ id: contact.id, address_book_id: keptBookId }], + }); + } finally { + await migrationPool.end(); + await rm(through0033Directory, { recursive: true, force: true }); + await rm(migration0034Directory, { recursive: true, force: true }); + } + }, 120_000); + + it('collapses empty duplicates by created_at and id and reruns 0034 as a no-op', async () => { + const through0033Directory = await createMigrationDirectory('0001', '0033'); + const migration0034Directory = await createMigrationDirectory('0034', '0034'); + const migrationPool = new Pool({ + connectionString: connectionStringFor(databaseNames.emptyDuplicates), + }); + const userId = randomUUID(); + const keptBookId = '10000000-0000-4000-8000-000000000001'; + + try { + await runMigrationsWithPool(migrationPool, through0033Directory); + await migrationPool.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-empty-${userId}`], + ); + await migrationPool.query(` + INSERT INTO address_books (id, user_id, name, source, external_url, created_at) + VALUES + ($1, $4, 'Same Time Lower ID', 'carddav', $5, '2026-01-01T00:00:00Z'), + ($2, $4, 'Same Time Higher ID', 'carddav', $5, '2026-01-01T00:00:00Z'), + ($3, $4, 'Later', 'carddav', $5, '2026-01-02T00:00:00Z') + `, [ + keptBookId, + '10000000-0000-4000-8000-000000000002', + '10000000-0000-4000-8000-000000000003', + userId, + 'https://dav.example.test/empty', + ]); + + await runMigrationsWithPool(migrationPool, migration0034Directory); + const { rows: booksAfterFirstRun } = await migrationPool.query(` + SELECT id + FROM address_books + WHERE user_id = $1 AND source = 'carddav' + ORDER BY created_at, id + `, [userId]); + expect(booksAfterFirstRun).toEqual([{ id: keptBookId }]); + const { rows: [index] } = await migrationPool.query( + "SELECT to_regclass('public.carddav_one_remote_book_idx') AS relation", + ); + expect(index.relation).toBe('carddav_one_remote_book_idx'); + + await runMigrationsWithPool(migrationPool, migration0034Directory); + expect((await migrationPool.query(` + SELECT id + FROM address_books + WHERE user_id = $1 AND source = 'carddav' + ORDER BY created_at, id + `, [userId])).rows).toEqual(booksAfterFirstRun); + const { rows: versions } = await migrationPool.query( + "SELECT version FROM schema_migrations WHERE version = '0034_carddav_incremental_sync'", + ); + expect(versions).toEqual([{ version: '0034_carddav_incremental_sync' }]); + } finally { + await migrationPool.end(); + await rm(through0033Directory, { recursive: true, force: true }); + await rm(migration0034Directory, { recursive: true, force: true }); + } + }, 120_000); + + it('upgrades a duplicate schema at migration 0033', async () => { + await withDatabase(databaseNames.upgrade, async client => { + await assertMinimumPostgresVersion(client); + await applyMigrations(client, '0001', '0033'); + + const userId = randomUUID(); + const secondMissingUserId = randomUUID(); + const existingStringUserId = randomUUID(); + const existingNullUserId = randomUUID(); + const nonCarddavUserId = randomUUID(); + const earliestBookId = randomUUID(); + const secondBookId = randomUUID(); + const thirdBookId = randomUUID(); + const externalUrl = 'https://carddav.example.test/books/shared'; + await client.query(` + INSERT INTO users (id, username) + VALUES + ($1, $6), + ($2, $7), + ($3, $8), + ($4, $9), + ($5, $10) + `, [ + userId, + secondMissingUserId, + existingStringUserId, + existingNullUserId, + nonCarddavUserId, + `carddav-upgrade-${userId}`, + `carddav-upgrade-${secondMissingUserId}`, + `carddav-upgrade-${existingStringUserId}`, + `carddav-upgrade-${existingNullUserId}`, + `carddav-upgrade-${nonCarddavUserId}`, + ]); + await client.query(` + INSERT INTO address_books (id, user_id, name, source, external_url, created_at) + VALUES + ($1, $4, 'Earliest', 'carddav', $5, '2026-01-01T00:00:00Z'), + ($2, $4, 'Second', 'carddav', $5, '2026-01-02T00:00:00Z'), + ($3, $4, 'Third', 'carddav', $5, '2026-01-03T00:00:00Z') + `, [earliestBookId, secondBookId, thirdBookId, userId, externalUrl]); + await client.query(` + INSERT INTO user_integrations (user_id, provider, config) + VALUES + ($1, 'carddav', '{"serverUrl":"https://dav.example.test","nested":{"keep":true}}'::jsonb), + ($2, 'carddav', '{"username":"second-missing","nested":{"preserve":true}}'::jsonb), + ($3, 'carddav', '{"connectionGeneration":"legacy-generation","keep":"value"}'::jsonb), + ($4, 'carddav', '{"connectionGeneration":null,"keep":"value"}'::jsonb), + ($5, 'caldav-test', '{"keep":"non-carddav"}'::jsonb) + `, [ + userId, + secondMissingUserId, + existingStringUserId, + existingNullUserId, + nonCarddavUserId, + ]); + + await applyMigrations(client, '0034', '0034'); + + const { rows: books } = await client.query(` + SELECT id, remote_sync_token, remote_sync_capability, remote_sync_revision + FROM address_books + WHERE user_id = $1 AND external_url = $2 + ORDER BY created_at, id + `, [userId, externalUrl]); + expect(books).toEqual([{ + id: earliestBookId, + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: '0', + }]); + + const { rows: integrations } = await client.query(` + SELECT provider, config + FROM user_integrations + WHERE user_id = $1 + ORDER BY provider + `, [userId]); + const carddavConfig = integrations.find(row => row.provider === 'carddav').config; + expect(carddavConfig.connectionGeneration).toMatch(UUID_PATTERN); + expect(carddavConfig).toEqual({ + connectionGeneration: carddavConfig.connectionGeneration, + nested: { keep: true }, + serverUrl: 'https://dav.example.test', + }); + + const { rows: generationRows } = await client.query(` + SELECT user_id, provider, config + FROM user_integrations + WHERE user_id = ANY($1::uuid[]) + ORDER BY user_id, provider + `, [[ + secondMissingUserId, + existingStringUserId, + existingNullUserId, + nonCarddavUserId, + ]]); + const generationConfigs = new Map(generationRows.map(row => [row.user_id, row.config])); + const secondMissingConfig = generationConfigs.get(secondMissingUserId); + expect(secondMissingConfig.connectionGeneration).toMatch(UUID_PATTERN); + expect(secondMissingConfig.connectionGeneration).not.toBe(carddavConfig.connectionGeneration); + expect(secondMissingConfig).toEqual({ + connectionGeneration: secondMissingConfig.connectionGeneration, + nested: { preserve: true }, + username: 'second-missing', + }); + expect(generationConfigs.get(existingStringUserId)).toEqual({ + connectionGeneration: 'legacy-generation', + keep: 'value', + }); + expect(generationConfigs.get(existingNullUserId)).toEqual({ + connectionGeneration: null, + keep: 'value', + }); + expect(generationConfigs.get(nonCarddavUserId)).toEqual({ keep: 'non-carddav' }); + + const separateContactId = randomUUID(); + const mergeContactId = randomUUID(); + await client.query(` + INSERT INTO contacts (id, address_book_id, user_id, uid, vcard, display_name) + VALUES + ($1, $3, $4, 'legacy-separate', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'Legacy Separate'), + ($2, $3, $4, 'legacy-merge', 'BEGIN:VCARD\r\nEND:VCARD\r\n', 'Legacy Merge') + `, [separateContactId, mergeContactId, earliestBookId, userId]); + await client.query(` + INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, disposition, + local_contact_id, merge_before, merge_applied + ) VALUES + ($1, '/legacy-separate.vcf', 'separate-etag', 'BEGIN:VCARD\r\nEND:VCARD\r\n', + 'separate@example.test', 'separate', $2, NULL, NULL), + ($1, '/legacy-merge.vcf', 'merge-etag', 'BEGIN:VCARD\r\nEND:VCARD\r\n', + 'merge@example.test', 'merge', $3, + '{"display_name":"Before"}'::jsonb, '{"display_name":"Remote"}'::jsonb), + ($1, '/legacy-skip.vcf', 'skip-etag', 'BEGIN:VCARD\r\nEND:VCARD\r\n', + 'skip@example.test', 'skip', $2, NULL, NULL) + `, [earliestBookId, separateContactId, mergeContactId]); + + await applyMigrations(client, '0035', '0035'); + + const { rows: legacyMappings } = await client.query(` + SELECT href, disposition, local_contact_id, merge_before, merge_applied, + mapping_status, mapping_revision, remote_semantic_hash, + local_contact_hash, legacy_projection + FROM carddav_remote_objects + WHERE address_book_id = $1 + ORDER BY href + `, [earliestBookId]); + expect(legacyMappings).toEqual([ + { + href: '/legacy-merge.vcf', + disposition: 'merge', + local_contact_id: mergeContactId, + merge_before: { display_name: 'Before' }, + merge_applied: { display_name: 'Remote' }, + mapping_status: 'pending_materialization', + mapping_revision: '0', + remote_semantic_hash: null, + local_contact_hash: null, + legacy_projection: { + disposition: 'merge', + merge_before: { display_name: 'Before' }, + merge_applied: { display_name: 'Remote' }, + }, + }, + { + href: '/legacy-separate.vcf', + disposition: 'separate', + local_contact_id: separateContactId, + merge_before: null, + merge_applied: null, + mapping_status: 'pending_materialization', + mapping_revision: '0', + remote_semantic_hash: null, + local_contact_hash: null, + legacy_projection: { + disposition: 'separate', + merge_before: null, + merge_applied: null, + }, + }, + { + href: '/legacy-skip.vcf', + disposition: 'skip', + local_contact_id: separateContactId, + merge_before: null, + merge_applied: null, + mapping_status: 'pending_materialization', + mapping_revision: '0', + remote_semantic_hash: null, + local_contact_hash: null, + legacy_projection: { + disposition: 'skip', + merge_before: null, + merge_applied: null, + }, + }, + ]); + + await client.query(` + UPDATE carddav_remote_objects + SET mapping_status = 'synced' + WHERE address_book_id = $1 AND href = '/legacy-separate.vcf' + `, [earliestBookId]); + await expect(client.query(` + UPDATE carddav_remote_objects + SET mapping_status = 'synced' + WHERE address_book_id = $1 AND href = '/legacy-skip.vcf' + `, [earliestBookId])).rejects.toMatchObject({ code: '23505' }); + await client.query(` + UPDATE carddav_remote_objects + SET mapping_status = 'pending_materialization' + WHERE address_book_id = $1 AND href = '/legacy-separate.vcf' + `, [earliestBookId]); + + const { rows: [defaultedMapping] } = await client.query(` + INSERT INTO carddav_remote_objects (address_book_id, href, vcard) + VALUES ($1, '/defaulted.vcf', 'BEGIN:VCARD\r\nEND:VCARD\r\n') + RETURNING disposition, mapping_status, mapping_revision + `, [earliestBookId]); + expect(defaultedMapping).toEqual({ + disposition: 'separate', + mapping_status: 'pending_materialization', + mapping_revision: '0', + }); + + await expectIncrementalSchema(client); + await expectBidirectionalSchema(client); + + await applyMigrations(client, '0036', '0036'); + await expectContractedBidirectionalSchema(client); + await applyMigrations(client, '0037', '0037'); + await expectConflictRetentionSchema(client); + await expect(client.query(` + UPDATE carddav_remote_objects + SET pending_operation = 'delete', pending_started_at = NOW() + WHERE address_book_id = $1 AND href = '/defaulted.vcf' + `, [earliestBookId])).rejects.toMatchObject({ code: '23514' }); + const { rows: [deleteIntent] } = await client.query(` + UPDATE carddav_remote_objects + SET pending_operation = 'delete', pending_local_hash = 'local-delete-hash', + pending_started_at = NOW() + WHERE address_book_id = $1 AND href = '/defaulted.vcf' + RETURNING pending_operation, pending_vcard, pending_local_hash, + pending_remote_semantic_hash, pending_started_at IS NOT NULL AS started + `, [earliestBookId]); + expect(deleteIntent).toEqual({ + pending_operation: 'delete', + pending_vcard: null, + pending_local_hash: 'local-delete-hash', + pending_remote_semantic_hash: null, + started: true, + }); + const { rows: [contractedIntegration] } = await client.query(` + SELECT config FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + `, [userId]); + expect(contractedIntegration.config).not.toHaveProperty('dupMode'); + }); + }, 120_000); + + it('creates the retained-conflict schema from migrations 0001 through 0037', async () => { + await withDatabase(databaseNames.fresh, async client => { + await assertMinimumPostgresVersion(client); + const freshUserId = randomUUID(); + await applyMigrations(client, '0001', '0033'); + await client.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [freshUserId, `carddav-fresh-${freshUserId}`], + ); + await client.query(` + INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb) + `, [freshUserId, JSON.stringify({ + serverUrl: 'https://fresh.example.test/dav/', + username: 'fresh-user', + dupMode: 'skip', + intervalMin: 45, + nested: { preserve: true }, + })]); + await applyMigrations(client, '0034', '0037'); + const { rows: [freshIntegration] } = await client.query(` + SELECT config + FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + `, [freshUserId]); + expect(freshIntegration.config.connectionGeneration).toMatch(UUID_PATTERN); + expect(freshIntegration.config).toEqual({ + connectionGeneration: freshIntegration.config.connectionGeneration, + intervalMin: 45, + nested: { preserve: true }, + serverUrl: 'https://fresh.example.test/dav/', + username: 'fresh-user', + }); + await expectContractedBidirectionalSchema(client); + await expectConflictRetentionSchema(client); + }); + }, 120_000); +}); diff --git a/backend/src/services/carddavProjection.js b/backend/src/services/carddavProjection.js new file mode 100644 index 00000000..e5ccbcdc --- /dev/null +++ b/backend/src/services/carddavProjection.js @@ -0,0 +1,63 @@ +export const normalizeEmail = value => value?.toLowerCase().trim() || null; +const nonBlank = value => typeof value === 'string' && value.trim() ? value : null; + +function compareRemote(left, right) { + const discoveryOrder = Number(left.discoveryIndex ?? 0) - Number(right.discoveryIndex ?? 0); + return discoveryOrder || left.href.localeCompare(right.href); +} + +function exactlyOne(candidates) { + return candidates.length === 1 ? candidates[0] : null; +} + +export function planAutomaticProjection({ remoteObjects, mappings, localContacts }) { + const orderedRemote = [...remoteObjects].sort(compareRemote); + const orderedLocal = [...localContacts].sort((left, right) => ( + String(left.id).localeCompare(String(right.id)) + )); + const localById = new Map(orderedLocal.map(contact => [contact.id, contact])); + const mappingByHref = new Map(mappings.map(mapping => [mapping.href, mapping])); + const claimedLocalIds = new Set(mappings + .map(mapping => mapping.localContactId) + .filter(Boolean)); + const available = orderedLocal.filter(contact => ( + (contact.isAuto === false || contact.is_auto === false) + && !claimedLocalIds.has(contact.id) + )); + const links = []; + const imports = []; + + for (const remote of orderedRemote) { + const mapping = mappingByHref.get(remote.href); + if (mapping?.localContactId && localById.has(mapping.localContactId)) { + links.push({ href: remote.href, localContactId: mapping.localContactId }); + continue; + } + + const candidates = available.filter(contact => !claimedLocalIds.has(contact.id)); + const uid = nonBlank(remote.contact?.uid); + let target = uid + ? exactlyOne(candidates.filter(contact => nonBlank(contact.uid) === uid)) + : null; + if (!target) { + const email = normalizeEmail(remote.contact?.primaryEmail); + if (email) { + target = exactlyOne(candidates.filter(contact => ( + normalizeEmail(contact.primaryEmail) === email + ))); + } + } + + if (!target) { + imports.push({ href: remote.href }); + continue; + } + claimedLocalIds.add(target.id); + links.push({ href: remote.href, localContactId: target.id }); + } + + const exports = available + .filter(contact => !claimedLocalIds.has(contact.id)) + .map(contact => ({ localContactId: contact.id })); + return { links, imports, exports }; +} diff --git a/backend/src/services/carddavProjection.test.js b/backend/src/services/carddavProjection.test.js new file mode 100644 index 00000000..98231c78 --- /dev/null +++ b/backend/src/services/carddavProjection.test.js @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { planAutomaticProjection } from './carddavProjection.js'; + +const remoteObject = (name, overrides = {}) => { + const object = { + href: `https://dav.example.test/book/${name}.vcf`, + discoveryIndex: 0, + contact: { + uid: name, + primaryEmail: `${name}@example.com`, + }, + }; + return { + ...object, + ...overrides, + contact: { ...object.contact, ...overrides.contact }, + }; +}; + +const localContact = (id, overrides = {}) => ({ + id, + isAuto: false, + uid: `local-${id}`, + primaryEmail: `${id}@example.com`, + ...overrides, +}); + +describe('planAutomaticProjection', () => { + it('keeps an existing mapping before considering automatic matches', () => { + const remote = remoteObject('mapped', { + contact: { uid: 'shared-uid', primaryEmail: 'shared@example.com' }, + }); + expect(planAutomaticProjection({ + remoteObjects: [remote], + mappings: [{ href: remote.href, localContactId: 'mapped-local' }], + localContacts: [ + localContact('mapped-local'), + localContact('unmapped-match', { + uid: 'shared-uid', primaryEmail: 'shared@example.com', + }), + ], + })).toEqual({ + links: [{ href: remote.href, localContactId: 'mapped-local' }], + imports: [], + exports: [{ localContactId: 'unmapped-match' }], + }); + }); + + it('links by one exact non-blank UID before a primary-email candidate', () => { + const remote = remoteObject('uid-first', { + contact: { uid: 'stable-uid', primaryEmail: 'email-match@example.com' }, + }); + expect(planAutomaticProjection({ + remoteObjects: [remote], + mappings: [], + localContacts: [ + localContact('email-match', { primaryEmail: ' EMAIL-MATCH@example.com ' }), + localContact('uid-match', { uid: 'stable-uid', primaryEmail: 'other@example.com' }), + ], + })).toEqual({ + links: [{ href: remote.href, localContactId: 'uid-match' }], + imports: [], + exports: [{ localContactId: 'email-match' }], + }); + }); + + it('links only one normalized primary-email candidate', () => { + const unique = remoteObject('unique', { + contact: { uid: 'remote-unique', primaryEmail: ' UNIQUE@example.com ' }, + }); + const ambiguous = remoteObject('ambiguous', { + contact: { uid: 'remote-ambiguous', primaryEmail: 'shared@example.com' }, + }); + expect(planAutomaticProjection({ + remoteObjects: [ambiguous, unique], + mappings: [], + localContacts: [ + localContact('unique', { uid: 'local-unique', primaryEmail: 'unique@EXAMPLE.COM' }), + localContact('shared-a', { uid: 'local-a', primaryEmail: 'shared@example.com' }), + localContact('shared-b', { uid: 'local-b', primaryEmail: ' SHARED@example.com ' }), + ], + })).toEqual({ + links: [{ href: unique.href, localContactId: 'unique' }], + imports: [{ href: ambiguous.href }], + exports: [{ localContactId: 'shared-a' }, { localContactId: 'shared-b' }], + }); + }); + + it('never matches prohibited fields, blanks, or auto contacts', () => { + const remote = remoteObject('prohibited', { + contact: { + uid: ' ', primaryEmail: '', displayName: 'Same', + phones: [{ value: '+15550000001' }], organization: 'Same Org', + }, + }); + expect(planAutomaticProjection({ + remoteObjects: [remote], + mappings: [], + localContacts: [ + localContact('explicit', { + uid: '', primaryEmail: '', displayName: 'Same', + phones: [{ value: '+15550000001' }], organization: 'Same Org', + }), + localContact('auto', { + uid: ' ', primaryEmail: '', displayName: 'Same', isAuto: true, + }), + ], + })).toEqual({ + links: [], + imports: [{ href: remote.href }], + exports: [{ localContactId: 'explicit' }], + }); + }); + + it('does not link an ambiguous UID or reuse one local contact', () => { + const first = remoteObject('a', { + contact: { uid: 'shared-uid', primaryEmail: 'a@example.com' }, + }); + const second = remoteObject('b', { + contact: { uid: 'shared-uid', primaryEmail: 'b@example.com' }, + }); + expect(planAutomaticProjection({ + remoteObjects: [second, first], + mappings: [], + localContacts: [ + localContact('candidate-a', { uid: 'shared-uid', primaryEmail: 'none-a@example.com' }), + localContact('candidate-b', { uid: 'shared-uid', primaryEmail: 'none-b@example.com' }), + ], + })).toEqual({ + links: [], + imports: [{ href: first.href }, { href: second.href }], + exports: [{ localContactId: 'candidate-a' }, { localContactId: 'candidate-b' }], + }); + }); + + it('orders books, hrefs, and local IDs without mutating inputs', () => { + const remoteObjects = [ + remoteObject('z', { discoveryIndex: 2 }), + remoteObject('b', { discoveryIndex: 0 }), + remoteObject('a', { discoveryIndex: 0 }), + ]; + const mappings = []; + const localContacts = [localContact('z-local'), localContact('a-local')]; + const before = structuredClone({ remoteObjects, mappings, localContacts }); + expect(planAutomaticProjection({ remoteObjects, mappings, localContacts })).toEqual({ + links: [], + imports: [ + { href: remoteObjects[2].href }, + { href: remoteObjects[1].href }, + { href: remoteObjects[0].href }, + ], + exports: [{ localContactId: 'a-local' }, { localContactId: 'z-local' }], + }); + expect({ remoteObjects, mappings, localContacts }).toEqual(before); + }); +}); diff --git a/backend/src/services/carddavSync.db.test.js b/backend/src/services/carddavSync.db.test.js new file mode 100644 index 00000000..38158cd4 --- /dev/null +++ b/backend/src/services/carddavSync.db.test.js @@ -0,0 +1,4091 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + applyTestMigrations, + assertMinimumPostgresVersion, + createTestDatabase, + dropTestDatabase, + postgresTestContext, + waitForPostgresState, +} from './postgresTestHelpers.js'; + +const mocks = vi.hoisted(() => ({ + decrypt: vi.fn(value => value), + discoverAddressBooks: vi.fn(), + fetchAddressBookDelta: vi.fn(), + getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })), + parseVCard: vi.fn(), + query: vi.fn(), + withTransaction: vi.fn(), +})); + +vi.mock('./db.js', () => ({ + query: mocks.query, + withTransaction: mocks.withTransaction, +})); +vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt })); +vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy })); +vi.mock('./carddavClient.js', () => ({ + discoverAddressBooks: mocks.discoverAddressBooks, + fetchAddressBookDelta: mocks.fetchAddressBookDelta, +})); +vi.mock('../utils/vcard.js', async importOriginal => { + const original = await importOriginal(); + mocks.parseVCard.mockImplementation(original.parseVCard); + return { ...original, parseVCard: mocks.parseVCard }; +}); + +const carddavSync = await import('./carddavSync.js'); +const carddavMappingState = await import('./carddavMappingState.js'); +const { generateVCard } = await vi.importActual('../utils/vcard.js'); +const { + localContactHash, + parseVCardDocument, + semanticVCardHash, +} = await vi.importActual('../utils/vcardProperties.js'); +const { Client } = pg; + +const USER_ID = '00000000-0000-4000-8000-000000000001'; +const BOOK_URL = 'https://dav.example.test/addressbooks/default/'; +const CONNECTION_GENERATION = 'generation-current'; + +function completePlan(overrides = {}) { + return { + userId: USER_ID, + book: { url: BOOK_URL, displayName: 'Remote' }, + connectionGeneration: CONNECTION_GENERATION, + expectedRemoteRevision: '0', + expectedRemoteToken: null, + nextRemoteToken: null, + capability: 'snapshot', + replaceAll: true, + upserts: [], + removedHrefs: [], + ...overrides, + }; +} + +function remoteCard(name, email) { + const href = `${BOOK_URL}${name}.vcf`; + const vcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:remote-${name}`, + `FN:${name}`, + `N:${name};${name};;;`, + `EMAIL:${email}`, + 'END:VCARD', + '', + ].join('\r\n'); + return { + href, + remoteEtag: `W/"remote-etag-${name}"`, + vcard, + contact: { + uid: `remote-${name}`, + displayName: name, + firstName: name, + lastName: name, + primaryEmail: email, + emails: [{ value: email, type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + }, + }; +} + +const { databaseUrl, connectionStringFor } = postgresTestContext('CardDAV transaction tests'); +const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations'); +const databaseName = `carddav_sync_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`; +let adminClient; +let databaseClient; + +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function applyMigrations(client, through = '0035') { + await applyTestMigrations(client, { migrationsDirectory, through }); +} + +async function beginApply(plan, queryOverride) { + await databaseClient.query('BEGIN'); + try { + const client = queryOverride + ? { query: (sql, params) => queryOverride(databaseClient, sql, params) } + : databaseClient; + const result = await carddavSync.applyBookDelta(client, plan); + await databaseClient.query('COMMIT'); + return result; + } catch (error) { + await databaseClient.query('ROLLBACK'); + throw error; + } +} + +function useDatabaseTransactions(queryOverride) { + mocks.withTransaction.mockImplementation(async callback => { + await databaseClient.query('BEGIN'); + try { + const client = queryOverride + ? { query: (sql, params) => queryOverride(databaseClient, sql, params) } + : databaseClient; + const result = await callback(client); + await databaseClient.query('COMMIT'); + return result; + } catch (error) { + await databaseClient.query('ROLLBACK'); + throw error; + } + }); +} + +async function persistedState(userId = USER_ID) { + const { rows: books } = await databaseClient.query(` + SELECT id, external_url, sync_token, remote_sync_token, remote_sync_capability, + remote_sync_revision::text, remote_projection_fingerprint + FROM address_books + WHERE user_id = $1 AND source = 'carddav' + ORDER BY external_url + `, [userId]); + const { rows: contacts } = await databaseClient.query(` + SELECT address_book_id, uid, vcard, etag, display_name, first_name, + last_name, primary_email, emails, phones, organization, notes, photo_data + FROM contacts + WHERE user_id = $1 + ORDER BY address_book_id, uid + `, [userId]); + const { rows: ledger } = await databaseClient.query(` + SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, o.primary_email, o.disposition, + o.local_contact_id, o.merge_before, o.merge_applied + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + ORDER BY o.href + `, [userId]); + const { rows: integrations } = await databaseClient.query(` + SELECT provider, config + FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + `, [userId]); + return { books, contacts, ledger, integrations }; +} + +async function persistedLifecycleState(userId) { + const state = await persistedState(userId); + const { rows: allBooks } = await databaseClient.query(` + SELECT id, source, external_url, sync_token, remote_sync_token, + remote_sync_capability, remote_sync_revision::text, + remote_projection_fingerprint + FROM address_books + WHERE user_id = $1 + ORDER BY id + `, [userId]); + return { ...state, allBooks }; +} + +async function seedLifecycleUser() { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/a/`; + const secondRemoteUrl = `https://dav.example.test/addressbooks/${userId}/b/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-lifecycle-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text, + 'lastError', 'before-error', + 'bookCount', 1, + 'contactCount', 1 + ))`, + [userId, generation], + ); + const { rows: [localBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Lifecycle Personal') RETURNING id, sync_token", + [userId], + ); + const { rows: [unrelatedBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Lifecycle Unrelated') RETURNING id, sync_token", + [userId], + ); + const targetVcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:lifecycle-target', + 'FN:Lifecycle Original', + 'EMAIL:lifecycle-duplicate@example.test', + 'END:VCARD', + '', + ].join('\r\n'); + const { rows: [target] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + primary_email, emails, phones, is_auto + ) VALUES ( + $1, $2, 'lifecycle-target', $3, $4, 'Lifecycle Original', 'Lifecycle', + 'lifecycle-duplicate@example.test', $5::jsonb, '[]'::jsonb, false + ) + RETURNING id + `, [ + localBook.id, + userId, + targetVcard, + createHash('md5').update(targetVcard).digest('hex'), + JSON.stringify([{ + value: 'lifecycle-duplicate@example.test', type: 'other', primary: true, + }]), + ]); + const secondTargetVcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:lifecycle-target-b', + 'FN:Lifecycle Original B', + 'EMAIL:lifecycle-duplicate-b@example.test', + 'END:VCARD', + '', + ].join('\r\n'); + const { rows: [secondTarget] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + primary_email, emails, phones, is_auto + ) VALUES ( + $1, $2, 'lifecycle-target-b', $3, $4, 'Lifecycle Original B', 'Lifecycle', + 'lifecycle-duplicate-b@example.test', $5::jsonb, '[]'::jsonb, false + ) + RETURNING id + `, [ + localBook.id, + userId, + secondTargetVcard, + createHash('md5').update(secondTargetVcard).digest('hex'), + JSON.stringify([{ + value: 'lifecycle-duplicate-b@example.test', type: 'other', primary: true, + }]), + ]); + const card = { + ...remoteCard('lifecycle-remote', 'lifecycle-duplicate@example.test'), + href: `${remoteUrl}lifecycle.vcf`, + }; + const skippedCard = { + ...remoteCard('lifecycle-b-skip', 'lifecycle-duplicate-b@example.test'), + href: `${secondRemoteUrl}lifecycle-b-skip.vcf`, + }; + const separateCard = { + ...remoteCard('lifecycle-separate', 'lifecycle-separate@example.test'), + href: `${remoteUrl}lifecycle-separate.vcf`, + }; + const secondMergeCard = { + ...remoteCard('lifecycle-b-merge', 'lifecycle-duplicate-b@example.test'), + href: `${secondRemoteUrl}lifecycle-b-merge.vcf`, + }; + for (const [url, name, upserts] of [ + [remoteUrl, 'Lifecycle Remote A', [card, separateCard]], + [secondRemoteUrl, 'Lifecycle Remote B', [secondMergeCard, skippedCard]], + ]) { + await beginApply(completePlan({ + userId, + book: { url, displayName: name }, + connectionGeneration: generation, + collectionIdentity: { observedUrl: url, canonicalUrl: url }, + upserts, + })); + } + const editedTargetVcard = targetVcard.replace( + 'END:VCARD\r\n', + 'NOTE:local lifecycle edit\r\nEND:VCARD\r\n', + ); + const editedSecondTargetVcard = secondTargetVcard.replace( + 'END:VCARD\r\n', + 'NOTE:local lifecycle edit B\r\nEND:VCARD\r\n', + ); + await databaseClient.query( + `UPDATE contacts SET + notes = CASE id + WHEN $1 THEN 'local lifecycle edit' + WHEN $2 THEN 'local lifecycle edit B' + END, + vcard = CASE id WHEN $1 THEN $3 ELSE $4 END, + etag = CASE id WHEN $1 THEN $5 ELSE $6 END + WHERE id = ANY($7::uuid[])`, + [ + target.id, + secondTarget.id, + editedTargetVcard, + editedSecondTargetVcard, + createHash('md5').update(editedTargetVcard).digest('hex'), + createHash('md5').update(editedSecondTargetVcard).digest('hex'), + [target.id, secondTarget.id], + ], + ); + const { rows: [seededLocalBook] } = await databaseClient.query( + 'SELECT id, sync_token FROM address_books WHERE id = $1', + [localBook.id], + ); + const { rows: remoteBooks } = await databaseClient.query( + `SELECT id, external_url, sync_token, remote_sync_revision::text + FROM address_books + WHERE user_id = $1 AND source = 'carddav' + ORDER BY external_url`, + [userId], + ); + return { + userId, + generation, + remoteUrl, + secondRemoteUrl, + remoteBooks, + localBook: seededLocalBook, + unrelatedBook, + target, + secondTarget, + }; +} + +async function seedLegacyCrossBookUser() { + const userId = randomUUID(); + const generation = randomUUID(); + const [bookAId, bookBId] = [randomUUID(), randomUUID()].sort(); + const bookAUrl = `https://dav.example.test/addressbooks/${userId}/legacy-a/`; + const bookBUrl = `https://dav.example.test/addressbooks/${userId}/legacy-b/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-legacy-cross-book-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: books } = await databaseClient.query( + `INSERT INTO address_books (id, user_id, name, source, external_url) + VALUES + ($1, $3, 'Legacy A', 'carddav', $4), + ($2, $3, 'Legacy B', 'carddav', $5) + RETURNING id, external_url, sync_token, remote_sync_revision::text`, + [bookAId, bookBId, userId, bookAUrl, bookBUrl], + ); + const { rows: [unrelatedBook] } = await databaseClient.query( + `INSERT INTO address_books (user_id, name) + VALUES ($1, 'Legacy Unrelated') RETURNING id, sync_token`, + [userId], + ); + const aMerge = { + ...remoteCard('legacy-a-merge', 'legacy-merge@example.test'), + href: `${bookAUrl}merge.vcf`, + }; + const aSkip = { + ...remoteCard('legacy-a-skip', 'legacy-skip@example.test'), + href: `${bookAUrl}skip.vcf`, + }; + const bMerge = { + ...remoteCard('legacy-b-merge', 'legacy-merge@example.test'), + href: `${bookBUrl}merge.vcf`, + }; + const bSkip = { + ...remoteCard('legacy-b-skip', 'legacy-skip@example.test'), + href: `${bookBUrl}skip.vcf`, + }; + for (const [book, cards] of [ + [{ url: bookAUrl, displayName: 'Legacy A' }, [aMerge, aSkip]], + [{ url: bookBUrl, displayName: 'Legacy B' }, [bMerge, bSkip]], + ]) { + await beginApply(completePlan({ + userId, + book, + connectionGeneration: generation, + collectionIdentity: { observedUrl: book.url, canonicalUrl: book.url }, + upserts: cards, + })); + } + const bookA = books.find(book => book.id === bookAId); + const bookB = books.find(book => book.id === bookBId); + const { rows: bContacts } = await databaseClient.query( + `SELECT c.id, c.uid, c.primary_email, c.emails + FROM contacts c + WHERE c.address_book_id = $1 + ORDER BY c.primary_email`, + [bookB.id], + ); + const bMergeContact = bContacts.find(contact => ( + contact.primary_email === 'legacy-merge@example.test' + )); + const bSkipContact = bContacts.find(contact => ( + contact.primary_email === 'legacy-skip@example.test' + )); + const mergeBefore = { + displayName: bMerge.contact.displayName, + firstName: bMerge.contact.firstName, + lastName: bMerge.contact.lastName, + phones: bMerge.contact.phones, + organization: bMerge.contact.organization, + notes: bMerge.contact.notes, + photoData: bMerge.contact.photoData, + }; + const mergeApplied = { + displayName: aMerge.contact.displayName, + firstName: aMerge.contact.firstName, + lastName: aMerge.contact.lastName, + phones: aMerge.contact.phones, + organization: aMerge.contact.organization, + notes: aMerge.contact.notes, + photoData: aMerge.contact.photoData, + }; + const mergedVcard = generateVCard({ + uid: bMergeContact.uid, + primaryEmail: bMergeContact.primary_email, + emails: bMergeContact.emails, + ...mergeApplied, + }); + await databaseClient.query( + `UPDATE contacts SET + display_name = $2, first_name = $3, last_name = $4, + phones = $5::jsonb, organization = $6, notes = $7, photo_data = $8, + vcard = $9, etag = $10 + WHERE id = $1`, + [ + bMergeContact.id, + mergeApplied.displayName, + mergeApplied.firstName, + mergeApplied.lastName, + JSON.stringify(mergeApplied.phones), + mergeApplied.organization, + mergeApplied.notes, + mergeApplied.photoData, + mergedVcard, + createHash('md5').update(mergedVcard).digest('hex'), + ], + ); + const { rows: aObjects } = await databaseClient.query( + `SELECT href, local_contact_id + FROM carddav_remote_objects + WHERE address_book_id = $1`, + [bookA.id], + ); + const aMergeObject = aObjects.find(object => object.href === aMerge.href); + const aSkipObject = aObjects.find(object => object.href === aSkip.href); + await databaseClient.query( + `UPDATE carddav_remote_objects SET + disposition = 'skip', local_contact_id = NULL, + merge_before = NULL, merge_applied = NULL, + mapping_status = 'pending_materialization', + legacy_projection = jsonb_build_object( + 'disposition', disposition, + 'merge_before', merge_before, + 'merge_applied', merge_applied + ) + WHERE address_book_id = $1`, + [bookB.id], + ); + await databaseClient.query( + `UPDATE carddav_remote_objects SET + disposition = 'merge', local_contact_id = $3, + merge_before = $4::jsonb, merge_applied = $5::jsonb, + mapping_status = 'pending_materialization', + legacy_projection = jsonb_build_object( + 'disposition', 'merge', + 'merge_before', $4::jsonb, + 'merge_applied', $5::jsonb + ) + WHERE address_book_id = $1 AND href = $2`, + [ + bookA.id, + aMerge.href, + bMergeContact.id, + JSON.stringify(mergeBefore), + JSON.stringify(mergeApplied), + ], + ); + await databaseClient.query( + `UPDATE carddav_remote_objects SET + disposition = 'skip', local_contact_id = $3, + merge_before = NULL, merge_applied = NULL, + mapping_status = 'pending_materialization', + legacy_projection = jsonb_build_object( + 'disposition', 'skip', + 'merge_before', NULL, + 'merge_applied', NULL + ) + WHERE address_book_id = $1 AND href = $2`, + [bookA.id, aSkip.href, bSkipContact.id], + ); + await databaseClient.query( + 'DELETE FROM contacts WHERE id = ANY($1::uuid[])', + [[aMergeObject.local_contact_id, aSkipObject.local_contact_id]], + ); + const { rows: seededBooks } = await databaseClient.query( + `SELECT id, external_url, sync_token, remote_sync_revision::text + FROM address_books WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[bookA.id, bookB.id]], + ); + return { + userId, + generation, + bookA: seededBooks.find(book => book.id === bookA.id), + bookB: seededBooks.find(book => book.id === bookB.id), + bookAUrl, + bookBUrl, + unrelatedBook, + aMerge, + aSkip, + bMerge, + bSkip, + bMergeContact, + bSkipContact, + }; +} + +async function probeBackendLock(pid) { + const { rows } = await adminClient.query( + 'SELECT wait_event_type FROM pg_stat_activity WHERE pid = $1', + [pid], + ); + const state = { wait_event_type: rows[0]?.wait_event_type ?? null }; + return { done: state.wait_event_type === 'Lock', state }; +} + +async function applyWithClient(client, plan, queryOverride) { + await client.query('BEGIN'); + try { + const transactionClient = queryOverride + ? { query: (sql, params) => queryOverride(client, sql, params) } + : client; + const result = await carddavSync.applyBookDelta(transactionClient, plan); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } +} + +async function automaticState(userId) { + const { rows: books } = await databaseClient.query( + `SELECT id, source, external_url, sync_token, remote_sync_token, + remote_sync_revision::text + FROM address_books WHERE user_id = $1 ORDER BY id`, + [userId], + ); + const { rows: contacts } = await databaseClient.query( + `SELECT id, address_book_id, display_name, photo_data, vcard, etag + FROM contacts WHERE user_id = $1 ORDER BY id`, + [userId], + ); + const { rows: ledger } = await databaseClient.query( + `SELECT o.address_book_id, o.href, o.disposition, o.local_contact_id, + o.merge_before, o.merge_applied + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 ORDER BY o.href`, + [userId], + ); + const { rows: [integration] } = await databaseClient.query( + `SELECT config FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav'`, + [userId], + ); + return { books, contacts, ledger, integration }; +} + +async function seedAutomaticPair() { + const userId = randomUUID(); + const generation = randomUUID(); + const [bookAId, bookBId] = [randomUUID(), randomUUID()].sort(); + const bookAUrl = `https://dav.example.test/addressbooks/${userId}/automatic-a/`; + const bookBUrl = `https://dav.example.test/addressbooks/${userId}/automatic-b/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-automatic-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: [targetBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Automatic Target') RETURNING id", + [userId], + ); + const { rows: [unrelatedBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Automatic Unrelated') RETURNING id", + [userId], + ); + await databaseClient.query( + `INSERT INTO address_books (id, user_id, name, source, external_url) + VALUES ($1, $3, 'Automatic Remote A', 'carddav', $4), + ($2, $3, 'Automatic Remote B', 'carddav', $5)`, + [bookAId, bookBId, userId, bookAUrl, bookBUrl], + ); + const target = { + uid: `automatic-target-${userId}`, + displayName: 'Automatic Original', + firstName: 'Automatic', + lastName: 'Original', + primaryEmail: `automatic-duplicate-${userId}@example.test`, + emails: [{ + value: `automatic-duplicate-${userId}@example.test`, type: 'other', primary: true, + }], + phones: [], + organization: null, + notes: 'Automatic local note', + photoData: null, + }; + const targetVcard = generateVCard(target); + const { rows: [targetContact] } = await databaseClient.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + last_name, primary_email, emails, phones, organization, notes, photo_data, is_auto + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '[]'::jsonb, + NULL, $11, NULL, false + ) RETURNING id`, + [ + targetBook.id, userId, target.uid, targetVcard, + createHash('md5').update(targetVcard).digest('hex'), target.displayName, + target.firstName, target.lastName, target.primaryEmail, + JSON.stringify(target.emails), target.notes, + ], + ); + const duplicate = { + ...remoteCard('Automatic Remote Duplicate', target.primaryEmail), + href: `${bookAUrl}duplicate.vcf`, + }; + const unique = { + ...remoteCard('Automatic Remote Unique', `automatic-unique-${userId}@example.test`), + href: `${bookBUrl}unique.vcf`, + }; + for (const [url, name, card, token] of [ + [bookAUrl, 'Automatic Remote A', duplicate, 'automatic-a-token'], + [bookBUrl, 'Automatic Remote B', unique, 'automatic-b-token'], + ]) { + await beginApply(completePlan({ + userId, + book: { url, displayName: name }, + connectionGeneration: generation, + expectedRemoteRevision: '0', + expectedRemoteToken: null, + nextRemoteToken: token, + capability: 'sync-collection', + collectionIdentity: { observedUrl: url, canonicalUrl: url }, + upserts: [card], + })); + } + return { + userId, + generation, + bookAId, + bookBId, + bookAUrl, + bookBUrl, + targetBook, + unrelatedBook, + targetContact, + target, + targetVcard, + duplicate, + unique, + before: await automaticState(userId), + }; +} + +async function seedLargeIncrementalUser() { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/large/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-large-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'serverUrl', 'https://dav.example.test/', + 'username', 'large-user', + 'password', 'encrypted', + 'connectionGeneration', $2::text, + 'contactCount', 10000 + ))`, + [userId, generation], + ); + const { rows: [book] } = await databaseClient.query( + `INSERT INTO address_books ( + user_id, name, source, external_url, remote_sync_token, + remote_sync_capability, remote_sync_revision, remote_projection_fingerprint + ) VALUES ($1, 'Large Remote', 'carddav', $2, 'large-token-0', + 'sync-collection', 0, $3) + RETURNING id, sync_token`, + [ + userId, + remoteUrl, + createHash('sha256').update(JSON.stringify([])).digest('hex'), + ], + ); + await databaseClient.query(` + WITH input AS ( + SELECT + i, + $2::text || 'large-' || lpad(i::text, 5, '0') || '.vcf' AS href, + 'large-' || lpad(i::text, 5, '0') AS remote_uid, + 'Large ' || lpad(i::text, 5, '0') AS display_name, + 'Large' AS first_name, + lpad(i::text, 5, '0') AS last_name, + 'l' || lpad(i::text, 5, '0') || '@example.test' AS email + FROM generate_series(1, 10000) AS series(i) + ), materialized AS ( + SELECT *, + 'BEGIN:VCARD' || E'\r\n' || + 'VERSION:3.0' || E'\r\n' || + 'UID:' || remote_uid || E'\r\n' || + 'FN:' || display_name || E'\r\n' || + 'N:' || last_name || ';' || first_name || ';;;' || E'\r\n' || + 'EMAIL:' || email || E'\r\n' || + 'END:VCARD' || E'\r\n' AS remote_vcard, + encode(sha256(convert_to(href, 'UTF8')), 'hex') AS local_uid + FROM input + ), local_materialized AS ( + SELECT *, + 'BEGIN:VCARD' || E'\r\n' || + 'VERSION:3.0' || E'\r\n' || + 'UID:' || local_uid || E'\r\n' || + 'FN:' || display_name || E'\r\n' || + 'N:' || last_name || ';' || first_name || ';;;' || E'\r\n' || + 'EMAIL;TYPE=OTHER:' || email || E'\r\n' || + 'END:VCARD' || E'\r\n' AS local_vcard + FROM materialized + ), inserted AS ( + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + first_name, last_name, primary_email, emails, phones, is_auto + ) + SELECT $1, $3, local_uid, local_vcard, md5(local_vcard), display_name, + first_name, last_name, email, + jsonb_build_array(jsonb_build_object( + 'value', email, 'type', 'other', 'primary', true + )), '[]'::jsonb, false + FROM local_materialized + RETURNING id, uid + ) + INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, + disposition, local_contact_id + ) + SELECT $1, materialized.href, '"large-' || lpad(materialized.i::text, 5, '0') || '-1"', + materialized.remote_vcard, materialized.email, 'separate', inserted.id + FROM materialized + JOIN inserted ON inserted.uid = materialized.local_uid + `, [book.id, remoteUrl, userId]); + return { userId, generation, remoteUrl, book }; +} + +describe('CardDAV full snapshot transaction', () => { + beforeAll(async () => { + adminClient = new Client({ connectionString: databaseUrl }); + await adminClient.connect(); + await createTestDatabase(adminClient, databaseName); + + databaseClient = new Client({ connectionString: connectionStringFor(databaseName) }); + await databaseClient.connect(); + await assertMinimumPostgresVersion(databaseClient); + await applyMigrations(databaseClient); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [USER_ID, `carddav-sync-${databaseName}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [USER_ID, CONNECTION_GENERATION], + ); + + const { rows: [localBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Personal') RETURNING id", + [USER_ID], + ); + const targetVcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:local-target', + 'FN:Local Target', + 'EMAIL:duplicate@example.test', + 'END:VCARD', + '', + ].join('\r\n'); + await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + primary_email, emails, phones, is_auto + ) VALUES ($1, $2, 'local-target', $3, $4, 'Local Target', 'Local', + 'duplicate@example.test', $5::jsonb, '[]'::jsonb, false) + `, [ + localBook.id, + USER_ID, + targetVcard, + createHash('md5').update(targetVcard).digest('hex'), + JSON.stringify([{ value: 'duplicate@example.test', type: 'other', primary: true }]), + ]); + }, 120_000); + + afterAll(async () => { + if (databaseClient) await databaseClient.end(); + if (adminClient) { + await dropTestDatabase(adminClient, databaseName); + await adminClient.end(); + } + }, 120_000); + + it('materializes upgraded separate, merge, and skip mappings atomically on PostgreSQL 16', async () => { + const transitionDatabase = `carddav_materialize_${process.pid}_${randomUUID() + .replaceAll('-', '').slice(0, 10)}`; + let transitionClient; + try { + await createTestDatabase(adminClient, transitionDatabase); + transitionClient = new Client({ connectionString: connectionStringFor(transitionDatabase) }); + await transitionClient.connect(); + await applyMigrations(transitionClient, '0034'); + + const userId = randomUUID(); + const generation = randomUUID(); + const localBookId = randomUUID(); + const modes = ['separate', 'merge', 'skip']; + const books = modes.map(mode => ({ + id: randomUUID(), + mode, + url: `https://dav.example.test/addressbooks/${userId}/${mode}/`, + })); + const cards = books.map(book => ({ + ...remoteCard(`legacy-${book.mode}`, `legacy-${book.mode}@example.test`), + href: `${book.url}contact.vcf`, + })); + + await transitionClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-materialize-${userId}`], + ); + await transitionClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text, 'contactCount', 2 + ))`, + [userId, generation], + ); + await transitionClient.query( + `INSERT INTO address_books (id, user_id, name) + VALUES ($1, $2, 'Legacy Explicit')`, + [localBookId, userId], + ); + for (const book of books) { + await transitionClient.query( + `INSERT INTO address_books (id, user_id, name, source, external_url) + VALUES ($1, $2, $3, 'carddav', $4)`, + [book.id, userId, `Legacy ${book.mode}`, book.url], + ); + } + + const localContacts = new Map(); + for (const mode of ['separate', 'merge']) { + const card = cards.find(candidate => candidate.href.includes(`/${mode}/`)); + const addressBookId = mode === 'separate' + ? books.find(book => book.mode === mode).id + : localBookId; + const uid = `legacy-${mode}-local`; + const vcard = generateVCard({ ...card.contact, uid }); + const { rows: [contact] } = await transitionClient.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + last_name, primary_email, emails, phones, organization, notes, photo_data, + is_auto + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,false + ) RETURNING id`, + [ + addressBookId, userId, uid, vcard, + createHash('md5').update(vcard).digest('hex'), + card.contact.displayName, card.contact.firstName, card.contact.lastName, + card.contact.primaryEmail, JSON.stringify(card.contact.emails), + JSON.stringify(card.contact.phones), card.contact.organization, + card.contact.notes, card.contact.photoData, + ], + ); + localContacts.set(mode, contact.id); + } + + for (const [index, mode] of modes.entries()) { + const mergeBefore = mode === 'merge' ? { display_name: 'Legacy Before' } : null; + const mergeApplied = mode === 'merge' ? { display_name: 'Legacy Remote' } : null; + await transitionClient.query( + `INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, disposition, + local_contact_id, merge_before, merge_applied + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb)`, + [ + books[index].id, cards[index].href, cards[index].remoteEtag, + cards[index].vcard, cards[index].contact.primaryEmail, mode, + localContacts.get(mode) || null, JSON.stringify(mergeBefore), + JSON.stringify(mergeApplied), + ], + ); + } + + await applyTestMigrations(transitionClient, { + migrationsDirectory, + first: '0035', + through: '0035', + }); + const { rows: pending } = await transitionClient.query( + `SELECT disposition, mapping_status, legacy_projection + FROM carddav_remote_objects ORDER BY disposition`, + ); + expect(pending).toEqual([ + expect.objectContaining({ + disposition: 'merge', + mapping_status: 'pending_materialization', + legacy_projection: expect.objectContaining({ disposition: 'merge' }), + }), + expect.objectContaining({ + disposition: 'separate', + mapping_status: 'pending_materialization', + legacy_projection: expect.objectContaining({ disposition: 'separate' }), + }), + expect.objectContaining({ + disposition: 'skip', + mapping_status: 'pending_materialization', + legacy_projection: expect.objectContaining({ disposition: 'skip' }), + }), + ]); + + for (const [index, book] of books.entries()) { + await transitionClient.query('BEGIN'); + try { + await carddavSync.applyBookDelta(transitionClient, completePlan({ + userId, + connectionGeneration: generation, + book: { url: book.url, displayName: `Legacy ${book.mode}` }, + collectionIdentity: { observedUrl: book.url, canonicalUrl: book.url }, + upserts: [cards[index]], + })); + await transitionClient.query('COMMIT'); + } catch (error) { + await transitionClient.query('ROLLBACK'); + throw error; + } + } + + const { rows: committed } = await transitionClient.query( + `SELECT o.href, o.disposition, o.vcard, o.local_contact_id, o.mapping_status, + o.vcard_version, o.remote_semantic_hash, o.local_contact_hash, + o.legacy_projection, o.last_synced_at, + c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones, + c.organization, c.notes, c.photo_data, c.additional_fields + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + ORDER BY o.disposition`, + ); + expect(committed).toHaveLength(3); + for (const mapping of committed) { + expect(mapping).toMatchObject({ + mapping_status: 'synced', + vcard_version: '3.0', + legacy_projection: null, + last_synced_at: expect.any(Date), + }); + expect(mapping.remote_semantic_hash) + .toBe(semanticVCardHash(parseVCardDocument(mapping.vcard))); + expect(mapping.local_contact_hash).toBe(localContactHash(mapping)); + } + + const rollbackBook = books.find(book => book.mode === 'separate'); + const rollbackCard = cards.find(card => card.href.includes('/separate/')); + const rollbackAttempt = { + ...rollbackCard, + remoteEtag: '"rollback-attempt"', + vcard: rollbackCard.vcard + .replace('FN:legacy-separate', 'FN:Rollback Attempt') + .replace('END:VCARD\r\n', 'NOTE:must roll back\r\nEND:VCARD\r\n'), + contact: { + ...rollbackCard.contact, + displayName: 'Rollback Attempt', + notes: 'must roll back', + }, + }; + await transitionClient.query( + `UPDATE carddav_remote_objects + SET mapping_status = 'pending_materialization', + remote_semantic_hash = NULL, + local_contact_hash = NULL, + last_synced_at = NULL, + legacy_projection = jsonb_build_object( + 'disposition', disposition, + 'merge_before', merge_before, + 'merge_applied', merge_applied + ) + WHERE address_book_id = $1`, + [rollbackBook.id], + ); + const readRollbackState = async () => { + const { rows: [mapping] } = await transitionClient.query( + `SELECT href, remote_etag, vcard, primary_email, disposition, + local_contact_id, merge_before, merge_applied, mapping_status, + mapping_revision::text, vcard_version, remote_semantic_hash, + local_contact_hash, legacy_projection, last_synced_at + FROM carddav_remote_objects WHERE address_book_id = $1`, + [rollbackBook.id], + ); + const { rows: [contact] } = await transitionClient.query( + `SELECT id, address_book_id, user_id, uid, vcard, etag, display_name, + first_name, last_name, primary_email, emails, phones, organization, + notes, photo_data, additional_fields, is_auto + FROM contacts WHERE id = $1`, + [mapping.local_contact_id], + ); + const { rows: [book] } = await transitionClient.query( + `SELECT remote_sync_token, remote_sync_revision::text + FROM address_books WHERE id = $1`, + [rollbackBook.id], + ); + return { mapping, contact, book }; + }; + const beforeRollback = await readRollbackState(); + let mappingWritten = false; + await transitionClient.query('BEGIN'); + try { + const failingClient = { + query: async (sql, params) => { + if (mappingWritten && /UPDATE address_books SET/.test(sql)) { + throw new Error('forced post-materialization failure'); + } + const result = await transitionClient.query(sql, params); + if (/(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) { + mappingWritten = true; + } + return result; + }, + }; + await expect(carddavSync.applyBookDelta(failingClient, completePlan({ + userId, + connectionGeneration: generation, + book: { url: rollbackBook.url, displayName: 'Legacy separate' }, + expectedRemoteRevision: beforeRollback.book.remote_sync_revision, + expectedRemoteToken: beforeRollback.book.remote_sync_token, + collectionIdentity: { + observedUrl: rollbackBook.url, + canonicalUrl: rollbackBook.url, + }, + upserts: [rollbackAttempt], + }))).rejects.toThrow('forced post-materialization failure'); + } finally { + await transitionClient.query('ROLLBACK'); + } + expect(mappingWritten).toBe(true); + expect(await readRollbackState()).toEqual(beforeRollback); + } finally { + if (transitionClient) await transitionClient.end(); + await dropTestDatabase(adminClient, transitionDatabase); + } + }, 120_000); + + it('uses the contracted 0036 schema even when config contains a stray dupMode key', async () => { + const contractedDatabase = `carddav_contracted_${process.pid}_${randomUUID() + .replaceAll('-', '').slice(0, 10)}`; + let contractedClient; + try { + await createTestDatabase(adminClient, contractedDatabase); + contractedClient = new Client({ + connectionString: connectionStringFor(contractedDatabase), + }); + await contractedClient.connect(); + await applyMigrations(contractedClient, '0036'); + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/contracted/`; + await contractedClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-contracted-${userId}`], + ); + await contractedClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text, 'dupMode', 'merge' + ))`, + [userId, generation], + ); + + await contractedClient.query('BEGIN'); + try { + const contractedCard = remoteCard('contracted', 'contracted@example.test'); + await expect(carddavSync.applyBookDelta(contractedClient, completePlan({ + userId, + connectionGeneration: generation, + book: { url: remoteUrl, displayName: 'Contracted' }, + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + upserts: [{ + ...contractedCard, + href: `${remoteUrl}contracted.vcf`, + }], + }))).resolves.toMatchObject({ remote: 1, updated: 1, removed: 0 }); + await contractedClient.query('COMMIT'); + } catch (error) { + await contractedClient.query('ROLLBACK'); + throw error; + } + const { rows: [beforeStale] } = await contractedClient.query(` + SELECT o.*, o.mapping_revision::text AS revision + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + `, [userId]); + await contractedClient.query('BEGIN'); + const stale = await carddavMappingState.applyConfirmedRemoteContact(contractedClient, { + addressBookId: beforeStale.address_book_id, + href: beforeStale.href, + expectedMappingRevision: String(BigInt(beforeStale.revision) + 1n), + remoteEtag: '"stale-must-not-write"', + vcard: beforeStale.vcard, + primaryEmail: beforeStale.primary_email, + localContactId: beforeStale.local_contact_id, + vcardVersion: beforeStale.vcard_version, + remoteSemanticHash: beforeStale.remote_semantic_hash, + localContactHash: beforeStale.local_contact_hash, + }); + await contractedClient.query('COMMIT'); + expect(stale).toMatchObject({ + ok: false, + stale: true, + code: 'ERR_CARDDAV_MAPPING_STALE', + expectedMappingRevision: String(BigInt(beforeStale.revision) + 1n), + }); + const { rows: [afterStale] } = await contractedClient.query(` + SELECT o.*, o.mapping_revision::text AS revision + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + `, [userId]); + expect(afterStale).toEqual(beforeStale); + } finally { + if (contractedClient) await contractedClient.end(); + await dropTestDatabase(adminClient, contractedDatabase); + } + }, 120_000); + + it('rotates connection generation only for committed identity or auth replacements', async () => { + const userId = randomUUID(); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-connection-${userId}`], + ); + useDatabaseTransactions(); + + const initial = await carddavSync.replaceCarddavConnection(userId, { + serverUrl: 'https://dav-a.example.test/', + username: 'user-a', + password: 'encrypted-a', + intervalMin: 60, + }); + expect(initial.connectionGeneration).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + + const interval = await carddavSync.patchCarddavConnection(userId, { intervalMin: 30 }); + expect(interval).toMatchObject({ + intervalMin: 30, + connectionGeneration: initial.connectionGeneration, + }); + + const server = await carddavSync.patchCarddavConnection(userId, { + serverUrl: 'https://dav-b.example.test/', + }); + expect(server.connectionGeneration).not.toBe(initial.connectionGeneration); + const username = await carddavSync.patchCarddavConnection(userId, { + username: 'user-b', + }); + expect(username.connectionGeneration).not.toBe(server.connectionGeneration); + const password = await carddavSync.patchCarddavConnection(userId, { + password: 'encrypted-b', + }); + expect(password.connectionGeneration).not.toBe(username.connectionGeneration); + expect(password).toMatchObject({ + serverUrl: 'https://dav-b.example.test/', + username: 'user-b', + password: 'encrypted-b', + intervalMin: 30, + }); + + const patched = await carddavSync.patchCarddavConnection(userId, { dupMode: 'merge' }); + expect(patched.connectionGeneration).toBe(password.connectionGeneration); + expect(patched).not.toHaveProperty('dupMode'); + + const replacement = await carddavSync.replaceCarddavConnection(userId, { + serverUrl: 'https://dav-c.example.test/', + username: 'user-c', + password: 'encrypted-c', + intervalMin: 45, + }); + expect(replacement.connectionGeneration).not.toBe(password.connectionGeneration); + expect(replacement).toMatchObject({ + serverUrl: 'https://dav-c.example.test/', + username: 'user-c', + password: 'encrypted-c', + intervalMin: 45, + lastError: null, + }); + }, 120_000); + + it('invalidates remote book identity state while preserving password-only state', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/identity/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-identity-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb)`, + [userId, JSON.stringify({ + serverUrl: 'https://dav.example.test/', + username: 'identity-a', + password: 'encrypted-a', + intervalMin: 60, + connectionGeneration: generation, + })], + ); + await beginApply(completePlan({ + userId, + book: { url: remoteUrl, displayName: 'Identity Remote' }, + connectionGeneration: generation, + nextRemoteToken: 'identity-token', + capability: 'sync-collection', + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + upserts: [{ + ...remoteCard('identity-retained', 'identity-retained@example.test'), + href: `${remoteUrl}retained.vcf`, + }], + })); + const beforePassword = await persistedLifecycleState(userId); + useDatabaseTransactions(); + + const passwordConfig = await carddavSync.patchCarddavConnection(userId, { + password: 'encrypted-b', + }); + const afterPassword = await persistedLifecycleState(userId); + expect(passwordConfig.connectionGeneration).not.toBe(generation); + expect({ + books: afterPassword.books, + contacts: afterPassword.contacts, + ledger: afterPassword.ledger, + allBooks: afterPassword.allBooks, + }).toEqual({ + books: beforePassword.books, + contacts: beforePassword.contacts, + ledger: beforePassword.ledger, + allBooks: beforePassword.allBooks, + }); + + const usernameConfig = await carddavSync.patchCarddavConnection(userId, { + username: 'identity-b', + }); + const afterUsername = await persistedLifecycleState(userId); + expect(usernameConfig.connectionGeneration).not.toBe(passwordConfig.connectionGeneration); + expect(afterUsername.contacts).toEqual(afterPassword.contacts); + expect(afterUsername.ledger).toEqual(afterPassword.ledger); + expect(afterUsername.books).toEqual([{ + ...afterPassword.books[0], + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: String(Number(afterPassword.books[0].remote_sync_revision) + 1), + remote_projection_fingerprint: null, + }]); + expect(afterUsername.allBooks).toEqual([{ + ...afterPassword.allBooks[0], + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: String(Number(afterPassword.allBooks[0].remote_sync_revision) + 1), + remote_projection_fingerprint: null, + }]); + }, 120_000); + + it('rolls back identity invalidation with a failed connection patch', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-identity-rollback-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb)`, + [userId, JSON.stringify({ + serverUrl: 'https://dav-before.example.test/', + username: 'identity-before', + password: 'encrypted-before', + connectionGeneration: generation, + })], + ); + await databaseClient.query( + `INSERT INTO address_books ( + user_id, name, source, external_url, remote_sync_token, + remote_sync_capability, remote_sync_revision, remote_projection_fingerprint + ) VALUES ($1, 'Identity Rollback', 'carddav', + 'https://dav-before.example.test/addressbooks/identity/', + 'identity-token-before', 'sync-collection', 9, 'fingerprint-before')`, + [userId], + ); + const before = await persistedLifecycleState(userId); + let identityInvalidated = false; + useDatabaseTransactions(async (client, sql, params) => { + if (/UPDATE address_books[\s\S]+remote_sync_capability = 'unknown'/.test(sql)) { + identityInvalidated = true; + } + if (/UPDATE user_integrations/.test(sql)) throw new Error('forced identity patch failure'); + return client.query(sql, params); + }); + + await expect(carddavSync.patchCarddavConnection(userId, { + serverUrl: 'https://dav-after.example.test/', + })).rejects.toThrow('forced identity patch failure'); + expect(identityInvalidated).toBe(true); + expect(await persistedLifecycleState(userId)).toEqual(before); + }, 120_000); + + it('classifies 10,000 mappings deterministically while writing only changed objects', async () => { + const fixture = await seedLargeIncrementalUser(); + const timestampsBefore = await databaseClient.query(` + SELECT href, created_at, updated_at + FROM carddav_remote_objects + WHERE address_book_id = $1 + ORDER BY href + `, [fixture.book.id]); + const noChangeSql = []; + const noChange = await beginApply(completePlan({ + userId: fixture.userId, + book: { url: fixture.remoteUrl, displayName: 'Large Remote' }, + connectionGeneration: fixture.generation, + expectedRemoteToken: 'large-token-0', + nextRemoteToken: 'large-token-1', + capability: 'sync-collection', + replaceAll: false, + }), async (client, sql, params) => { + noChangeSql.push([sql, params]); + return client.query(sql, params); + }); + expect(noChange).toMatchObject({ changedBookIds: [], ledgerChanged: false }); + expect(noChangeSql.some(([sql]) => ( + /FROM carddav_remote_objects/.test(sql) && /ORDER BY b\.id, o\.href/.test(sql) + ))).toBe(true); + expect(noChangeSql.some(([sql]) => /FROM contacts/.test(sql))).toBe(true); + expect(noChangeSql.some(([sql]) => ( + /(?:INSERT INTO|DELETE FROM|UPDATE) carddav_remote_objects/.test(sql) + ))).toBe(false); + const timestampsAfterNoChange = await databaseClient.query(` + SELECT href, created_at, updated_at + FROM carddav_remote_objects + WHERE address_book_id = $1 + ORDER BY href + `, [fixture.book.id]); + expect(timestampsAfterNoChange.rows).toEqual(timestampsBefore.rows); + + const changedHref = `${fixture.remoteUrl}large-05000.vcf`; + const changed = { + ...remoteCard('Large Changed', 'l05000@example.test'), + href: changedHref, + remoteEtag: '"large-05000-2"', + }; + const unrelatedBefore = await databaseClient.query(` + SELECT o.*, c.* + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + WHERE o.address_book_id = $1 AND o.href = $2 + `, [fixture.book.id, `${fixture.remoteUrl}large-00001.vcf`]); + const updateSql = []; + await beginApply(completePlan({ + userId: fixture.userId, + book: { url: fixture.remoteUrl, displayName: 'Large Remote' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: '1', + expectedRemoteToken: 'large-token-1', + nextRemoteToken: 'large-token-2', + capability: 'sync-collection', + replaceAll: false, + upserts: [changed], + }), async (client, sql, params) => { + updateSql.push([sql, params]); + return client.query(sql, params); + }); + const ledgerReads = updateSql.filter(([sql]) => ( + /SELECT[\s\S]+FROM carddav_remote_objects/.test(sql) + )); + expect(ledgerReads.length).toBeGreaterThan(0); + expect(ledgerReads.some(([sql]) => /ORDER BY b\.id, o\.href/.test(sql))).toBe(true); + expect(updateSql.some(([sql]) => /FROM contacts/.test(sql))).toBe(true); + const ledgerUpserts = updateSql.filter(([sql]) => ( + /UPDATE carddav_remote_objects/.test(sql) + )); + expect(ledgerUpserts).toHaveLength(1); + expect(ledgerUpserts[0][1]).toContain(changedHref); + expect(await databaseClient.query(` + SELECT o.*, c.* + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + WHERE o.address_book_id = $1 AND o.href = $2 + `, [fixture.book.id, `${fixture.remoteUrl}large-00001.vcf`])) + .toEqual(unrelatedBefore); + + const removalSql = []; + await beginApply(completePlan({ + userId: fixture.userId, + book: { url: fixture.remoteUrl, displayName: 'Large Remote' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: '2', + expectedRemoteToken: 'large-token-2', + nextRemoteToken: 'large-token-3', + capability: 'sync-collection', + replaceAll: false, + removedHrefs: [changedHref], + }), async (client, sql, params) => { + removalSql.push([sql, params]); + return client.query(sql, params); + }); + const ledgerDeletes = removalSql.filter(([sql]) => ( + /DELETE FROM carddav_remote_objects/.test(sql) + )); + expect(ledgerDeletes).toHaveLength(1); + expect(ledgerDeletes[0][1]).toEqual([fixture.book.id, changedHref, '1']); + expect(removalSql.some(([sql]) => ( + /SELECT[\s\S]+FROM carddav_remote_objects/.test(sql) + && /ORDER BY b\.id, o\.href/.test(sql) + ))).toBe(true); + const { rows: [{ count }] } = await databaseClient.query( + 'SELECT COUNT(*)::int AS count FROM carddav_remote_objects WHERE address_book_id = $1', + [fixture.book.id], + ); + expect(count).toBe(9999); + + const { rows: [newTargetBook] } = await databaseClient.query( + `INSERT INTO address_books (user_id, name) + VALUES ($1, 'Large New Target') RETURNING id, sync_token`, + [fixture.userId], + ); + const fingerprintReconciliationSql = []; + await beginApply(completePlan({ + userId: fixture.userId, + book: { url: fixture.remoteUrl, displayName: 'Large Remote' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: '3', + expectedRemoteToken: 'large-token-3', + nextRemoteToken: 'large-token-4', + capability: 'sync-collection', + replaceAll: false, + }), async (client, sql, params) => { + fingerprintReconciliationSql.push([sql, params]); + return client.query(sql, params); + }); + expect(fingerprintReconciliationSql.some(([sql]) => ( + /FROM carddav_remote_objects/.test(sql) && /ORDER BY b\.id, o\.href/.test(sql) + ))).toBe(true); + expect(fingerprintReconciliationSql.some(([sql]) => ( + /FROM contacts/.test(sql) && !/ANY\(/.test(sql) + ))).toBe(true); + const { rows: [reconciledBook] } = await databaseClient.query( + `SELECT remote_projection_fingerprint + FROM address_books WHERE id = $1`, + [fixture.book.id], + ); + expect(reconciledBook.remote_projection_fingerprint).toBe( + createHash('sha256').update(JSON.stringify([ + [newTargetBook.id, newTargetBook.sync_token], + ])).digest('hex'), + ); + + const finalizerSql = []; + useDatabaseTransactions(async (client, sql, params) => { + finalizerSql.push([sql, params]); + return client.query(sql, params); + }); + await carddavSync.finalizeCarddavSync(fixture.userId, { + connectionGeneration: fixture.generation, + seenUrls: [fixture.remoteUrl], + status: { lastError: null }, + }); + expect(finalizerSql.some(([sql]) => /FROM carddav_remote_objects/.test(sql))).toBe(true); + expect(finalizerSql.some(([sql]) => /FROM contacts/.test(sql))).toBe(false); + }, 120_000); + + it('rolls back a failed connection patch without changing config or generation', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-connection-rollback-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb)`, + [userId, JSON.stringify({ + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted-before', + intervalMin: 60, + connectionGeneration: generation, + })], + ); + const before = await persistedState(userId); + useDatabaseTransactions(async (client, sql, params) => { + if (/UPDATE user_integrations/.test(sql)) throw new Error('forced connection patch failure'); + return client.query(sql, params); + }); + + await expect(carddavSync.patchCarddavConnection(userId, { + password: 'encrypted-after', + })).rejects.toThrow('forced connection patch failure'); + expect(await persistedState(userId)).toEqual(before); + }, 120_000); + + it('rejects a password patch preflighted against a replaced generation', async () => { + const userId = randomUUID(); + const preflightedGeneration = randomUUID(); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-password-preflight-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb)`, + [userId, JSON.stringify({ + serverUrl: 'https://dav-a.example.test/', + username: 'user-a', + password: 'encrypted-a', + intervalMin: 60, + connectionGeneration: preflightedGeneration, + })], + ); + useDatabaseTransactions(); + const replacement = await carddavSync.replaceCarddavConnection(userId, { + serverUrl: 'https://dav-b.example.test/', + username: 'user-b', + password: 'encrypted-b', + intervalMin: 30, + }); + const beforeStalePatch = await persistedState(userId); + + await expect(carddavSync.patchCarddavConnection( + userId, + { password: 'encrypted-after-preflight' }, + preflightedGeneration, + )).rejects.toMatchObject({ + name: 'StaleCarddavPlanError', + expectedConnectionGeneration: preflightedGeneration, + actualConnectionGeneration: replacement.connectionGeneration, + }); + expect(await persistedState(userId)).toEqual(beforeStalePatch); + }, 120_000); + + it('records failure status only for the exact expected generation', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-failure-status-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb)`, + [userId, JSON.stringify({ + serverUrl: 'https://dav.example.test/', + connectionGeneration: generation, + lastError: 'before', + lastSyncAt: '2026-01-01T00:00:00.000Z', + })], + ); + mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params)); + + expect(await carddavSync.recordCarddavSyncFailure( + userId, 'stale-generation', new Error('stale error'), + )).toBe(false); + expect(await carddavSync.recordCarddavSyncFailure( + userId, generation, new Error('current error'), + )).toBe(true); + const { rows: [{ config }] } = await databaseClient.query( + `SELECT config FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav'`, + [userId], + ); + expect(config).toMatchObject({ + connectionGeneration: generation, + lastError: 'current error', + }); + expect(config.lastSyncAt).not.toBe('2026-01-01T00:00:00.000Z'); + + await databaseClient.query( + `UPDATE user_integrations + SET config = jsonb_set(config, '{connectionGeneration}', 'null'::jsonb) + WHERE user_id = $1 AND provider = 'carddav'`, + [userId], + ); + expect(await carddavSync.recordCarddavSyncFailure( + userId, null, new Error('legacy null error'), + )).toBe(true); + }, 120_000); + + it('fences old planned work after production connection replacement', async () => { + vi.clearAllMocks(); + const fixture = await seedLifecycleUser(); + await databaseClient.query( + `UPDATE user_integrations + SET config = config || $2::jsonb + WHERE user_id = $1 AND provider = 'carddav'`, + [fixture.userId, JSON.stringify({ + serverUrl: 'https://dav-a.example.test/', + username: 'user-a', + password: 'encrypted-a', + intervalMin: 60, + lastSyncAt: '2026-07-10T12:00:00.000Z', + })], + ); + const before = await persistedLifecycleState(fixture.userId); + const staleCard = { + ...remoteCard('replacement-stale', 'replacement-stale@example.test'), + href: `${fixture.remoteUrl}replacement-stale.vcf`, + }; + const oldApplyRequested = deferred(); + const releaseOldApply = deferred(); + let transactionCalls = 0; + mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params)); + mocks.discoverAddressBooks.mockResolvedValue([{ + url: fixture.remoteUrl, + displayName: 'Lifecycle Remote A', + supportsSyncCollection: true, + }]); + mocks.fetchAddressBookDelta.mockImplementationOnce(async request => ({ + expectedRemoteToken: request.syncToken, + nextRemoteToken: 'replacement-must-not-commit', + capability: 'sync-collection', + replaceAll: false, + upserts: [staleCard], + removedHrefs: [], + })); + mocks.withTransaction.mockImplementation(async callback => { + transactionCalls++; + if (transactionCalls === 1) { + oldApplyRequested.resolve(); + await releaseOldApply.promise; + } + await databaseClient.query('BEGIN'); + try { + const result = await callback(databaseClient); + await databaseClient.query('COMMIT'); + return result; + } catch (error) { + await databaseClient.query('ROLLBACK'); + throw error; + } + }); + + const oldSync = carddavSync.syncUser(fixture.userId); + await oldApplyRequested.promise; + const replacement = await carddavSync.replaceCarddavConnection(fixture.userId, { + serverUrl: 'https://dav-b.example.test/', + username: 'user-b', + password: 'encrypted-b', + intervalMin: 30, + }); + const afterReplacement = await persistedLifecycleState(fixture.userId); + releaseOldApply.resolve(); + + await expect(oldSync).resolves.toMatchObject({ + ok: false, + error: 'CardDAV sync plan is stale', + }); + expect(replacement).toEqual({ + serverUrl: 'https://dav-b.example.test/', + username: 'user-b', + password: 'encrypted-b', + intervalMin: 30, + connectionGeneration: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), + lastError: null, + contactCount: before.integrations[0].config.contactCount, + }); + expect(replacement.connectionGeneration).not.toBe(fixture.generation); + expect(afterReplacement.integrations).toEqual([{ + provider: 'carddav', + config: replacement, + }]); + expect(afterReplacement.contacts).toEqual(before.contacts); + expect(afterReplacement.ledger).toEqual(before.ledger); + expect(afterReplacement.books).toEqual(before.books.map(book => ({ + ...book, + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: String(Number(book.remote_sync_revision) + 1), + remote_projection_fingerprint: null, + }))); + expect(afterReplacement.allBooks).toEqual(before.allBooks.map(book => ( + book.source === 'carddav' ? { + ...book, + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: String(Number(book.remote_sync_revision) + 1), + remote_projection_fingerprint: null, + } : book + ))); + expect(await persistedLifecycleState(fixture.userId)).toEqual(afterReplacement); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce(); + expect(mocks.withTransaction).toHaveBeenCalledTimes(2); + expect(mocks.query).toHaveBeenCalledWith( + expect.stringMatching(/UPDATE user_integrations[\s\S]+connectionGeneration/), + [fixture.userId, expect.stringContaining('CardDAV sync plan is stale'), fixture.generation], + ); + }, 120_000); + + it('fences old planned work after production projection-aware disconnect', async () => { + vi.clearAllMocks(); + const fixture = await seedLifecycleUser(); + await databaseClient.query( + `UPDATE user_integrations + SET config = config || $2::jsonb + WHERE user_id = $1 AND provider = 'carddav'`, + [fixture.userId, JSON.stringify({ + serverUrl: 'https://dav-a.example.test/', + username: 'user-a', + password: 'encrypted-a', + intervalMin: 60, + lastSyncAt: '2026-07-10T12:00:00.000Z', + })], + ); + const before = await persistedLifecycleState(fixture.userId); + const staleCard = { + ...remoteCard('disconnect-stale', 'disconnect-stale@example.test'), + href: `${fixture.remoteUrl}disconnect-stale.vcf`, + }; + const oldApplyRequested = deferred(); + const releaseOldApply = deferred(); + let transactionCalls = 0; + mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params)); + mocks.discoverAddressBooks.mockResolvedValue([{ + url: fixture.remoteUrl, + displayName: 'Lifecycle Remote A', + supportsSyncCollection: true, + }]); + mocks.fetchAddressBookDelta.mockImplementationOnce(async request => ({ + expectedRemoteToken: request.syncToken, + nextRemoteToken: 'disconnect-must-not-commit', + capability: 'sync-collection', + replaceAll: false, + upserts: [staleCard], + removedHrefs: [], + })); + mocks.withTransaction.mockImplementation(async callback => { + transactionCalls++; + if (transactionCalls === 1) { + oldApplyRequested.resolve(); + await releaseOldApply.promise; + } + await databaseClient.query('BEGIN'); + try { + const result = await callback(databaseClient); + await databaseClient.query('COMMIT'); + return result; + } catch (error) { + await databaseClient.query('ROLLBACK'); + throw error; + } + }); + + const oldSync = carddavSync.syncUser(fixture.userId); + await oldApplyRequested.promise; + await expect(carddavSync.disconnectCarddavAccount(fixture.userId)).resolves.toBe(true); + const afterDisconnect = await persistedLifecycleState(fixture.userId); + releaseOldApply.resolve(); + + await expect(oldSync).resolves.toMatchObject({ + ok: false, + error: 'CardDAV sync plan is stale', + }); + expect(afterDisconnect.integrations).toEqual([]); + expect(afterDisconnect.books).toEqual([]); + expect(afterDisconnect.ledger).toEqual([]); + expect(afterDisconnect.allBooks.map(book => ({ + id: book.id, + tokenChanged: book.sync_token !== before.allBooks.find(beforeBook => ( + beforeBook.id === book.id + )).sync_token, + }))).toEqual([ + { id: fixture.localBook.id, tokenChanged: false }, + { id: fixture.unrelatedBook.id, tokenChanged: false }, + ].sort((left, right) => left.id.localeCompare(right.id))); + expect(afterDisconnect.contacts.find(contact => contact.uid === 'lifecycle-target')) + .toMatchObject({ + display_name: 'Lifecycle Original', + notes: 'local lifecycle edit', + }); + expect(afterDisconnect.contacts.find(contact => contact.uid === 'lifecycle-target-b')) + .toMatchObject({ + display_name: 'Lifecycle Original B', + notes: 'local lifecycle edit B', + }); + const { rows: [orphans] } = await databaseClient.query( + `SELECT COUNT(*)::int AS count + FROM carddav_remote_objects o + LEFT JOIN address_books b ON b.id = o.address_book_id + WHERE b.id IS NULL`, + ); + expect(orphans.count).toBe(0); + expect(await persistedLifecycleState(fixture.userId)).toEqual(afterDisconnect); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce(); + expect(mocks.withTransaction).toHaveBeenCalledTimes(2); + expect(mocks.query).toHaveBeenCalledWith( + expect.stringMatching(/UPDATE user_integrations[\s\S]+connectionGeneration/), + [fixture.userId, expect.stringContaining('CardDAV sync plan is stale'), fixture.generation], + ); + }, 120_000); + + it('restarts from the integration lock when the PostgreSQL projection footprint expands', async () => { + vi.clearAllMocks(); + const fixture = await seedAutomaticPair(); + await databaseClient.query( + `UPDATE user_integrations + SET config = config || $2::jsonb + WHERE user_id = $1 AND provider = 'carddav'`, + [fixture.userId, JSON.stringify({ + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + })], + ); + const source = fixture.before.books.find(book => book.id === fixture.bookAId); + mocks.query.mockImplementation((sql, params) => databaseClient.query(sql, params)); + mocks.discoverAddressBooks.mockResolvedValue([{ + url: source.external_url, + displayName: 'Automatic Remote A', + supportsSyncCollection: true, + }]); + mocks.fetchAddressBookDelta.mockImplementation(async request => ({ + expectedRemoteToken: request.syncToken, + nextRemoteToken: request.syncToken, + capability: 'sync-collection', + replaceAll: false, + upserts: [], + removedHrefs: [], + })); + const targetBooksLocked = deferred(); + const releaseApply = deferred(); + let blocked = false; + useDatabaseTransactions(async (client, sql, params) => { + const result = await client.query(sql, params); + if (!blocked && /source <> 'carddav'[\s\S]+FOR UPDATE/.test(sql)) { + blocked = true; + targetBooksLocked.resolve(); + await releaseApply.promise; + } + return result; + }); + + const pending = carddavSync.syncUser(fixture.userId); + await targetBooksLocked.promise; + const concurrent = new Client({ connectionString: connectionStringFor(databaseName) }); + await concurrent.connect(); + await concurrent.query( + `INSERT INTO address_books (user_id, name) + VALUES ($1, 'Concurrent footprint target')`, + [fixture.userId], + ); + await concurrent.end(); + releaseApply.resolve(); + + await expect(pending).resolves.toMatchObject({ ok: true, bookCount: 1 }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce(); + expect(mocks.withTransaction).toHaveBeenCalledTimes(3); + }, 120_000); + + it('persists projection fingerprint from final eligible target tokens', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-fingerprint-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: localBooks } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Fingerprint Target'), ($1, 'Fingerprint Unrelated') + RETURNING id, name, sync_token + `, [userId]); + const targetBook = localBooks.find(book => book.name === 'Fingerprint Target'); + const unrelatedBook = localBooks.find(book => book.name === 'Fingerprint Unrelated'); + const targetVcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:fingerprint-target', + 'FN:Fingerprint Original', + 'EMAIL:fingerprint@example.test', + 'END:VCARD', + '', + ].join('\r\n'); + await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, is_auto + ) VALUES ( + $1, $2, 'fingerprint-target', $3, $4, 'Fingerprint Original', + 'fingerprint@example.test', $5::jsonb, '[]'::jsonb, false + ) + `, [ + targetBook.id, + userId, + targetVcard, + createHash('md5').update(targetVcard).digest('hex'), + JSON.stringify([{ + value: 'fingerprint@example.test', type: 'other', primary: true, + }]), + ]); + + await beginApply(completePlan({ + userId, + connectionGeneration: generation, + book: { url: remoteUrl, displayName: 'Fingerprint Remote' }, + upserts: [{ + ...remoteCard('fingerprint-merge', 'fingerprint@example.test'), + href: `${remoteUrl}fingerprint-merge.vcf`, + }], + })); + + const { rows: finalBooks } = await databaseClient.query(` + SELECT id, source, sync_token, remote_projection_fingerprint + FROM address_books + WHERE user_id = $1 + ORDER BY id + `, [userId]); + const finalTarget = finalBooks.find(book => book.id === targetBook.id); + const finalUnrelated = finalBooks.find(book => book.id === unrelatedBook.id); + const remoteBook = finalBooks.find(book => book.source === 'carddav'); + const fingerprintInputs = [ + [finalTarget.id, finalTarget.sync_token], + [finalUnrelated.id, finalUnrelated.sync_token], + ].sort(([left], [right]) => left.localeCompare(right)); + const expectedFingerprint = createHash('sha256') + .update(JSON.stringify(fingerprintInputs)) + .digest('hex'); + + expect({ + targetTokenRotated: finalTarget.sync_token !== targetBook.sync_token, + unrelatedTokenStable: finalUnrelated.sync_token === unrelatedBook.sync_token, + fingerprint: remoteBook.remote_projection_fingerprint, + }).toEqual({ + targetTokenRotated: false, + unrelatedTokenStable: true, + fingerprint: expectedFingerprint, + }); + }, 120_000); + + it('persists complete provenance, applies a delta, and rolls back exact local and remote tokens', async () => { + const cards = [ + remoteCard('a-separate', 'new@example.test'), + remoteCard('b-merge', 'duplicate@example.test'), + remoteCard('c-skip', 'duplicate@example.test'), + ]; + const plan = completePlan({ upserts: cards }); + + const first = await beginApply(plan); + const afterFirst = await persistedState(); + + expect(first).toMatchObject({ remote: 3, updated: 2, removed: 0 }); + expect(first).not.toHaveProperty('count'); + expect(first.changedBookIds).not.toEqual([]); + expect(first).not.toHaveProperty('visibleChanged'); + expect(afterFirst.ledger.map(row => ({ + href: row.href, + remoteEtag: row.remote_etag, + hasLocalContact: row.local_contact_id !== null, + }))).toEqual([ + { + href: cards[0].href, + remoteEtag: cards[0].remoteEtag, + hasLocalContact: true, + }, + { + href: cards[1].href, + remoteEtag: cards[1].remoteEtag, + hasLocalContact: true, + }, + { + href: cards[2].href, + remoteEtag: cards[2].remoteEtag, + hasLocalContact: true, + }, + ]); + const { rows: confirmedMappings } = await databaseClient.query( + `SELECT href, mapping_status, remote_semantic_hash, local_contact_hash, + legacy_projection, last_synced_at + FROM carddav_remote_objects + WHERE address_book_id = $1 + ORDER BY href`, + [afterFirst.books[0].id], + ); + expect(confirmedMappings).toHaveLength(3); + for (const mapping of confirmedMappings) { + expect(mapping).toMatchObject({ + mapping_status: 'synced', + remote_semantic_hash: expect.any(String), + local_contact_hash: expect.any(String), + legacy_projection: null, + last_synced_at: expect.any(Date), + }); + } + const mirrored = afterFirst.contacts.find(contact => contact.primary_email === 'new@example.test'); + expect(mirrored.uid).toBe(createHash('sha256').update(cards[0].href).digest('hex')); + expect(mirrored.uid).not.toBe(cards[0].contact.uid); + expect(mirrored.vcard).toContain(`UID:${mirrored.uid}\r\n`); + expect(mirrored.vcard).toContain('FN:a-separate\r\n'); + expect(mirrored.etag).toBe(createHash('md5').update(mirrored.vcard).digest('hex')); + const linked = afterFirst.contacts.find(contact => contact.uid === 'local-target'); + expect(linked).toMatchObject({ + display_name: 'Local Target', + primary_email: 'duplicate@example.test', + }); + expect(linked.vcard).toContain('FN:Local Target\r\n'); + const secondImport = afterFirst.contacts.find(contact => ( + contact.uid === createHash('sha256').update(cards[2].href).digest('hex') + )); + expect(secondImport).toMatchObject({ + display_name: 'c-skip', + primary_email: 'duplicate@example.test', + }); + + const unchanged = await beginApply({ + ...plan, + expectedRemoteRevision: afterFirst.books[0].remote_sync_revision, + }); + const afterUnchanged = await persistedState(); + expect(unchanged.changedBookIds).toEqual([]); + expect(unchanged).not.toHaveProperty('visibleChanged'); + expect(afterUnchanged.books[0].sync_token).toBe(afterFirst.books[0].sync_token); + + const tokenPlan = completePlan({ + expectedRemoteRevision: afterUnchanged.books[0].remote_sync_revision, + nextRemoteToken: ' opaque-token-before ', + upserts: cards, + }); + await beginApply(tokenPlan); + const beforeDelta = await persistedState(); + expect(beforeDelta.books[0].remote_sync_token).toBe(' opaque-token-before '); + + const changed = remoteCard('a-separate', 'new@example.test'); + changed.contact.displayName = 'Changed Name'; + changed.contact.firstName = 'Changed'; + changed.vcard = changed.vcard.replaceAll('a-separate', 'Changed Name'); + const changedPlan = completePlan({ + expectedRemoteRevision: beforeDelta.books[0].remote_sync_revision, + expectedRemoteToken: ' opaque-token-before ', + nextRemoteToken: 'remote-token-after', + replaceAll: false, + upserts: [changed], + removedHrefs: [cards[2].href], + }); + let contactMutated = false; + await expect(beginApply(changedPlan, async (client, sql, params) => { + if (/(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) { + expect(contactMutated).toBe(true); + throw new Error('forced post-contact failure'); + } + const result = await client.query(sql, params); + if (sql.includes('INSERT INTO contacts') || sql.includes('UPDATE contacts SET') + || sql.includes('DELETE FROM contacts')) { + contactMutated = true; + } + return result; + })).rejects.toThrow('forced post-contact failure'); + + expect(await persistedState()).toEqual(beforeDelta); + }, 120_000); + + it('replaces a canonical collection alias in place and rolls the rename back atomically', async () => { + const aliasUrl = 'https://dav.example.test/addressbooks/alias-pg/'; + const canonicalUrl = 'https://dav.example.test/addressbooks/canonical-pg/'; + const retained = { + ...remoteCard('alias-retained', 'alias-retained@example.test'), + href: `${canonicalUrl}retained.vcf`, + }; + const removed = { + ...remoteCard('alias-removed', 'alias-removed@example.test'), + href: `${canonicalUrl}removed.vcf`, + }; + await beginApply(completePlan({ + book: { url: aliasUrl, displayName: 'Alias PG' }, + nextRemoteToken: 'alias-token-before', + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl }, + upserts: [retained, removed], + })); + const { rows: [beforeBook] } = await databaseClient.query( + `SELECT id, remote_sync_revision::text + FROM address_books + WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`, + [USER_ID, aliasUrl], + ); + expect(beforeBook.remote_sync_revision).toBe('1'); + + const changed = { + ...retained, + remoteEtag: 'W/"canonical-etag"', + vcard: retained.vcard.replaceAll('alias-retained', 'canonical-retained'), + contact: { + ...retained.contact, + displayName: 'canonical-retained', + firstName: 'canonical-retained', + lastName: 'canonical-retained', + }, + }; + await beginApply(completePlan({ + book: { url: aliasUrl, displayName: 'Alias PG' }, + expectedRemoteRevision: '1', + expectedRemoteToken: 'alias-token-before', + nextRemoteToken: 'canonical-token-after', + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl }, + upserts: [changed], + })); + + const { rows: books } = await databaseClient.query( + `SELECT id, external_url, remote_sync_token, remote_sync_revision::text + FROM address_books + WHERE id = $1 OR (user_id = $2 AND external_url = $3) + ORDER BY id`, + [beforeBook.id, USER_ID, aliasUrl], + ); + expect(books).toEqual([{ + id: beforeBook.id, + external_url: canonicalUrl, + remote_sync_token: 'canonical-token-after', + remote_sync_revision: '2', + }]); + const { rows: ledger } = await databaseClient.query( + `SELECT address_book_id, href, remote_etag + FROM carddav_remote_objects + WHERE address_book_id = $1 + ORDER BY href`, + [beforeBook.id], + ); + expect(ledger).toEqual([{ + address_book_id: beforeBook.id, + href: changed.href, + remote_etag: changed.remoteEtag, + }]); + + const beforeRollback = await persistedState(); + await expect(beginApply(completePlan({ + book: { url: canonicalUrl, displayName: 'Alias PG' }, + expectedRemoteRevision: '2', + expectedRemoteToken: 'canonical-token-after', + nextRemoteToken: 'rollback-token', + collectionIdentity: { + observedUrl: canonicalUrl, + canonicalUrl: `${canonicalUrl}renamed/`, + }, + upserts: [changed], + }), async (client, sql, params) => { + const result = await client.query(sql, params); + if (/UPDATE address_books SET/.test(sql)) throw new Error('forced post-rename failure'); + return result; + })).rejects.toThrow('forced post-rename failure'); + expect(await persistedState()).toEqual(beforeRollback); + }, 120_000); + + it('rejects a canonical URL conflict before mutating projection state', async () => { + const aliasUrl = 'https://dav.example.test/addressbooks/conflict-alias/'; + const canonicalUrl = 'https://dav.example.test/addressbooks/conflict-canonical/'; + await beginApply(completePlan({ + book: { url: canonicalUrl, displayName: 'Canonical Owner' }, + collectionIdentity: { observedUrl: canonicalUrl, canonicalUrl }, + })); + await beginApply(completePlan({ + book: { url: aliasUrl, displayName: 'Conflicting Alias' }, + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl }, + })); + const { rows: [owner] } = await databaseClient.query( + `SELECT id FROM address_books + WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`, + [USER_ID, canonicalUrl], + ); + const before = await persistedState(); + + const error = await beginApply(completePlan({ + book: { url: aliasUrl, displayName: 'Conflicting Alias' }, + expectedRemoteRevision: '1', + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl }, + })).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + reason: 'canonical-url-conflict', + observedUrl: aliasUrl, + canonicalUrl, + conflictingBookId: owner.id, + }); + expect(await persistedState()).toEqual(before); + }, 120_000); + + it('turns a concurrent canonical insert into typed stale state with exact rollback', async () => { + const aliasUrl = 'https://dav.example.test/addressbooks/racing-alias/'; + const canonicalUrl = 'https://dav.example.test/addressbooks/racing-canonical/'; + await beginApply(completePlan({ + book: { url: aliasUrl, displayName: 'Racing Alias' }, + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl }, + upserts: [remoteCard('racing-alias', 'racing-alias@example.test')], + })); + const { rows: [beforeAlias] } = await databaseClient.query( + `SELECT id, external_url, remote_sync_revision::text, remote_sync_token, sync_token + FROM address_books + WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`, + [USER_ID, aliasUrl], + ); + const booksLocked = deferred(); + const releaseApply = deferred(); + const applyPromise = beginApply(completePlan({ + book: { url: aliasUrl, displayName: 'Racing Alias' }, + expectedRemoteRevision: beforeAlias.remote_sync_revision, + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl }, + }), async (client, sql, params) => { + const result = await client.query(sql, params); + if (/external_url = ANY\(\$2::text\[\]\)/.test(sql)) { + booksLocked.resolve(); + await releaseApply.promise; + } + return result; + }); + + await booksLocked.promise; + const concurrent = new Client({ connectionString: connectionStringFor(databaseName) }); + await concurrent.connect(); + const { rows: [canonicalBook] } = await concurrent.query( + `INSERT INTO address_books (user_id, name, source, external_url) + VALUES ($1, 'Racing Canonical', 'carddav', $2) + RETURNING id`, + [USER_ID, canonicalUrl], + ); + await concurrent.end(); + releaseApply.resolve(); + + const error = await applyPromise.catch(caught => caught); + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + reason: 'canonical-url-conflict', + observedUrl: aliasUrl, + canonicalUrl, + conflictingBookId: canonicalBook.id, + }); + const { rows: [afterAlias] } = await databaseClient.query( + `SELECT id, external_url, remote_sync_revision::text, remote_sync_token, sync_token + FROM address_books + WHERE id = $1`, + [beforeAlias.id], + ); + expect(afterAlias).toEqual(beforeAlias); + }, 120_000); + + it('rejects reciprocal alias replacements without a book-lock deadlock', async () => { + const firstUrl = 'https://dav.example.test/addressbooks/reciprocal-a/'; + const secondUrl = 'https://dav.example.test/addressbooks/reciprocal-b/'; + for (const [url, name] of [[firstUrl, 'A'], [secondUrl, 'B']]) { + await beginApply(completePlan({ + book: { url, displayName: `Reciprocal ${name}` }, + collectionIdentity: { observedUrl: url, canonicalUrl: url }, + })); + } + const before = await persistedState(); + const firstClient = new Client({ connectionString: connectionStringFor(databaseName) }); + const secondClient = new Client({ connectionString: connectionStringFor(databaseName) }); + await Promise.all([firstClient.connect(), secondClient.connect()]); + + async function applyReciprocal(client, observedUrl, canonicalUrl) { + await client.query('BEGIN'); + try { + await client.query("SET LOCAL lock_timeout = '5s'"); + const result = await carddavSync.applyBookDelta(client, completePlan({ + book: { url: observedUrl, displayName: 'Reciprocal' }, + expectedRemoteRevision: '1', + collectionIdentity: { observedUrl, canonicalUrl }, + })); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + } + + const results = await Promise.allSettled([ + applyReciprocal(firstClient, firstUrl, secondUrl), + applyReciprocal(secondClient, secondUrl, firstUrl), + ]); + await Promise.all([firstClient.end(), secondClient.end()]); + + expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']); + for (const result of results) { + expect(result.reason).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(result.reason).toMatchObject({ reason: 'canonical-url-conflict' }); + expect(result.reason.code).not.toBe('40P01'); + } + expect(await persistedState()).toEqual(before); + }, 120_000); + + it('finalizes a canonical alias replacement without pruning the renamed book', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const aliasUrl = `https://dav.example.test/addressbooks/${userId}/alias/`; + const canonicalUrl = `https://dav.example.test/addressbooks/${userId}/canonical/`; + const card = { + ...remoteCard('alias-finalize', 'alias-finalize@example.test'), + href: `${canonicalUrl}alias-finalize.vcf`, + }; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-alias-finalize-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + await beginApply(completePlan({ + userId, + book: { url: aliasUrl, displayName: 'Alias Finalize' }, + connectionGeneration: generation, + nextRemoteToken: 'alias-before', + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl: aliasUrl }, + upserts: [card], + })); + const { rows: [aliasBook] } = await databaseClient.query( + `SELECT id, remote_sync_revision::text + FROM address_books WHERE user_id = $1 AND external_url = $2`, + [userId, aliasUrl], + ); + await beginApply(completePlan({ + userId, + book: { url: aliasUrl, displayName: 'Alias Finalize' }, + connectionGeneration: generation, + expectedRemoteRevision: aliasBook.remote_sync_revision, + expectedRemoteToken: 'alias-before', + nextRemoteToken: 'canonical-after', + collectionIdentity: { observedUrl: aliasUrl, canonicalUrl }, + upserts: [card], + })); + const beforeFinalize = await persistedLifecycleState(userId); + useDatabaseTransactions(); + + await carddavSync.finalizeCarddavSync(userId, { + connectionGeneration: generation, + seenUrls: [canonicalUrl], + status: { + lastSyncAt: '2026-07-10T12:00:00.000Z', + lastError: null, + bookCount: 1, + contactCount: 1, + }, + }); + + const afterFinalize = await persistedLifecycleState(userId); + expect(afterFinalize.books).toEqual(beforeFinalize.books); + expect(afterFinalize.contacts).toEqual(beforeFinalize.contacts); + expect(afterFinalize.ledger).toEqual(beforeFinalize.ledger); + expect(afterFinalize.books).toHaveLength(1); + expect(afterFinalize.books[0]).toMatchObject({ + id: aliasBook.id, + external_url: canonicalUrl, + remote_sync_token: 'canonical-after', + }); + }, 120_000); + + it('finalizes a valid empty account without rewriting linked explicit contacts', async () => { + const fixture = await seedLifecycleUser(); + useDatabaseTransactions(); + const { rows: [beforeLocal] } = await databaseClient.query( + 'SELECT sync_token FROM address_books WHERE id = $1', + [fixture.localBook.id], + ); + const status = { + lastSyncAt: '2026-07-10T12:00:00.000Z', + lastError: null, + bookCount: 0, + contactCount: 0, + }; + + await carddavSync.finalizeCarddavSync(fixture.userId, { + connectionGeneration: fixture.generation, + seenUrls: [], + status, + }); + + const { rows: remoteBooks } = await databaseClient.query( + "SELECT id FROM address_books WHERE user_id = $1 AND source = 'carddav'", + [fixture.userId], + ); + expect(remoteBooks).toEqual([]); + const { rows: [restored] } = await databaseClient.query( + `SELECT display_name, notes, vcard, etag + FROM contacts WHERE id = $1`, + [fixture.target.id], + ); + expect(restored).toMatchObject({ + display_name: 'Lifecycle Original', + notes: 'local lifecycle edit', + }); + expect(restored.vcard).toContain('FN:Lifecycle Original\r\n'); + expect(restored.vcard).toContain('NOTE:local lifecycle edit\r\n'); + expect(restored.etag).toBe(createHash('md5').update(restored.vcard).digest('hex')); + const { rows: tokens } = await databaseClient.query( + `SELECT id, sync_token FROM address_books + WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[fixture.localBook.id, fixture.unrelatedBook.id]], + ); + expect(tokens.find(row => row.id === fixture.localBook.id).sync_token) + .toBe(beforeLocal.sync_token); + expect(tokens.find(row => row.id === fixture.unrelatedBook.id).sync_token) + .toBe(fixture.unrelatedBook.sync_token); + const { rows: [integration] } = await databaseClient.query( + `SELECT config FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav'`, + [fixture.userId], + ); + expect(integration.config).toMatchObject(status); + }, 120_000); + + it('finalizes valid empty status for a legacy null connection generation', async () => { + const fixture = await seedLifecycleUser(); + await databaseClient.query( + `UPDATE user_integrations + SET config = jsonb_set(config, '{connectionGeneration}', 'null'::jsonb) + WHERE user_id = $1 AND provider = 'carddav'`, + [fixture.userId], + ); + useDatabaseTransactions(); + const status = { + lastSyncAt: '2026-07-10T13:00:00.000Z', + lastError: null, + bookCount: 0, + contactCount: 0, + }; + + await carddavSync.finalizeCarddavSync(fixture.userId, { + connectionGeneration: null, + seenUrls: [], + status, + }); + + const { rows: remoteBooks } = await databaseClient.query( + `SELECT id FROM address_books + WHERE user_id = $1 AND source = 'carddav'`, + [fixture.userId], + ); + expect(remoteBooks).toEqual([]); + const { rows: [integration] } = await databaseClient.query( + `SELECT config FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav'`, + [fixture.userId], + ); + expect(integration.config).toMatchObject({ + ...status, + connectionGeneration: null, + }); + }, 120_000); + + it('prunes one of multiple remote books with the exact lifecycle token and revision set', async () => { + const fixture = await seedLifecycleUser(); + const staleBook = fixture.remoteBooks.find(book => book.external_url === fixture.remoteUrl); + const survivingBook = fixture.remoteBooks.find(book => ( + book.external_url === fixture.secondRemoteUrl + )); + useDatabaseTransactions(); + + await carddavSync.finalizeCarddavSync(fixture.userId, { + connectionGeneration: fixture.generation, + seenUrls: [fixture.secondRemoteUrl], + status: { + lastSyncAt: '2026-07-10T14:00:00.000Z', + lastError: null, + bookCount: 1, + contactCount: 0, + }, + }); + + const { rows: books } = await databaseClient.query( + `SELECT id, sync_token, remote_sync_revision::text + FROM address_books + WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[ + staleBook.id, + survivingBook.id, + fixture.localBook.id, + fixture.unrelatedBook.id, + ]], + ); + expect(books.find(book => book.id === staleBook.id)).toBeUndefined(); + expect(books.find(book => book.id === survivingBook.id)).toEqual({ + id: survivingBook.id, + sync_token: survivingBook.sync_token, + remote_sync_revision: survivingBook.remote_sync_revision, + }); + expect(books.find(book => book.id === fixture.localBook.id).sync_token) + .toBe(fixture.localBook.sync_token); + expect(books.find(book => book.id === fixture.unrelatedBook.id).sync_token) + .toBe(fixture.unrelatedBook.sync_token); + const { rows: [restored] } = await databaseClient.query( + `SELECT display_name, notes, vcard, etag + FROM contacts WHERE id = $1`, + [fixture.target.id], + ); + expect(restored).toMatchObject({ + display_name: 'Lifecycle Original', + notes: 'local lifecycle edit', + }); + expect(restored.etag).toBe(createHash('md5').update(restored.vcard).digest('hex')); + }, 120_000); + + it('prunes a stale book without reclassifying an untouched surviving mapping', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const staleUrl = `https://dav.example.test/addressbooks/${userId}/stale/`; + const survivorUrl = `https://dav.example.test/addressbooks/${userId}/survivor/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-reproject-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: books } = await databaseClient.query( + `INSERT INTO address_books (user_id, name, source, external_url) + VALUES + ($1, 'Stale Source', 'carddav', $2), + ($1, 'Surviving Source', 'carddav', $3) + RETURNING id, external_url, sync_token`, + [userId, staleUrl, survivorUrl], + ); + const staleBook = books.find(book => book.external_url === staleUrl); + const survivorBook = books.find(book => book.external_url === survivorUrl); + const vcard = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:shared-remote', 'FN:Shared Remote', + 'EMAIL:shared-remote@example.test', 'END:VCARD', '', + ].join('\r\n'); + const localVcard = generateVCard({ + uid: 'shared-projected', + displayName: 'Shared Remote', + firstName: 'Shared', + lastName: 'Remote', + primaryEmail: 'shared-remote@example.test', + emails: [{ value: 'shared-remote@example.test', type: 'other', primary: true }], + phones: [], + }); + const { rows: [projected] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + last_name, primary_email, emails, phones, is_auto + ) VALUES ( + $1, $2, 'shared-projected', $3, $4, 'Shared Remote', 'Shared', 'Remote', + 'shared-remote@example.test', $5::jsonb, '[]'::jsonb, false + ) RETURNING id + `, [ + staleBook.id, + userId, + localVcard, + createHash('md5').update(localVcard).digest('hex'), + JSON.stringify([{ + value: 'shared-remote@example.test', type: 'other', primary: true, + }]), + ]); + await databaseClient.query(` + INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, + disposition, local_contact_id + ) VALUES + ($1, $2, '"stale"', $3, 'shared-remote@example.test', 'separate', $4), + ($5, $6, '"survivor"', $3, 'shared-remote@example.test', 'skip', NULL) + `, [ + staleBook.id, + `${staleUrl}shared.vcf`, + vcard, + projected.id, + survivorBook.id, + `${survivorUrl}shared.vcf`, + ]); + useDatabaseTransactions(); + + await carddavSync.finalizeCarddavSync(userId, { + connectionGeneration: generation, + seenUrls: [survivorUrl], + status: { + lastSyncAt: '2026-07-10T12:00:00.000Z', + lastError: null, + bookCount: 1, + contactCount: 1, + }, + }); + + const { rows: [survivorObject] } = await databaseClient.query( + `SELECT disposition, local_contact_id + FROM carddav_remote_objects WHERE address_book_id = $1`, + [survivorBook.id], + ); + expect(survivorObject).toMatchObject({ + disposition: 'skip', + local_contact_id: null, + }); + const { rows: [survivorAfter] } = await databaseClient.query( + `SELECT sync_token, remote_sync_revision::text + FROM address_books WHERE id = $1`, + [survivorBook.id], + ); + expect(survivorAfter.sync_token).toBe(survivorBook.sync_token); + expect(survivorAfter.remote_sync_revision).toBe('0'); + const { rows: staleAfter } = await databaseClient.query( + 'SELECT id FROM address_books WHERE id = $1', + [staleBook.id], + ); + expect(staleAfter).toEqual([]); + }, 120_000); + + it('materializes ordered legacy cross-book mappings into unique automatic owners', async () => { + const fixture = await seedLegacyCrossBookUser(); + for (const [book, cards] of [ + [fixture.bookA, [fixture.aMerge, fixture.aSkip]], + [fixture.bookB, [fixture.bMerge, fixture.bSkip]], + ]) { + await beginApply(completePlan({ + userId: fixture.userId, + book: { url: book.external_url, displayName: 'Legacy Repair' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: book.remote_sync_revision, + collectionIdentity: { + observedUrl: book.external_url, + canonicalUrl: book.external_url, + }, + upserts: cards, + })); + } + + const { rows: books } = await databaseClient.query( + `SELECT id, sync_token, remote_sync_revision::text + FROM address_books + WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[fixture.bookA.id, fixture.bookB.id, fixture.unrelatedBook.id]], + ); + const bookA = books.find(book => book.id === fixture.bookA.id); + const bookB = books.find(book => book.id === fixture.bookB.id); + const unrelated = books.find(book => book.id === fixture.unrelatedBook.id); + expect(bookA.sync_token).not.toBe(fixture.bookA.sync_token); + expect(bookB.sync_token).not.toBe(fixture.bookB.sync_token); + expect(unrelated.sync_token).toBe(fixture.unrelatedBook.sync_token); + expect(bookA.remote_sync_revision) + .toBe(String(BigInt(fixture.bookA.remote_sync_revision) + 1n)); + expect(bookB.remote_sync_revision) + .toBe(String(BigInt(fixture.bookB.remote_sync_revision) + 1n)); + const { rows: repairedObjects } = await databaseClient.query( + `SELECT o.href, o.mapping_status, o.legacy_projection, o.local_contact_id, + c.address_book_id AS contact_book_id + FROM carddav_remote_objects o + LEFT JOIN contacts c ON c.id = o.local_contact_id + WHERE o.address_book_id = $1 ORDER BY o.href`, + [fixture.bookA.id], + ); + expect(repairedObjects).toEqual([ + { + href: fixture.aMerge.href, + mapping_status: 'synced', + legacy_projection: null, + local_contact_id: expect.any(String), + contact_book_id: fixture.bookA.id, + }, + { + href: fixture.aSkip.href, + mapping_status: 'synced', + legacy_projection: null, + local_contact_id: expect.any(String), + contact_book_id: fixture.bookA.id, + }, + ]); + const { rows: [restored] } = await databaseClient.query( + `SELECT display_name, vcard, etag + FROM contacts WHERE id = $1`, + [fixture.bMergeContact.id], + ); + expect(restored.display_name).toBe(fixture.bMerge.contact.displayName); + expect(restored.vcard).toContain(`FN:${fixture.bMerge.contact.displayName}\r\n`); + expect(restored.etag).toBe(createHash('md5').update(restored.vcard).digest('hex')); + }, 120_000); + + it('prunes a legacy sibling after materializing the surviving automatic owners', async () => { + const fixture = await seedLegacyCrossBookUser(); + await beginApply(completePlan({ + userId: fixture.userId, + book: { url: fixture.bookA.external_url, displayName: 'Legacy Survivor' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: fixture.bookA.remote_sync_revision, + collectionIdentity: { + observedUrl: fixture.bookA.external_url, + canonicalUrl: fixture.bookA.external_url, + }, + upserts: [fixture.aMerge, fixture.aSkip], + })); + const { rows: [materializedBook] } = await databaseClient.query( + `SELECT id, sync_token, remote_sync_revision::text + FROM address_books WHERE id = $1`, + [fixture.bookA.id], + ); + const { rows: [unrelatedBefore] } = await databaseClient.query( + `SELECT id, source, external_url, sync_token, remote_sync_token, + remote_sync_capability, remote_sync_revision::text, + remote_projection_fingerprint + FROM address_books WHERE id = $1`, + [fixture.unrelatedBook.id], + ); + useDatabaseTransactions(); + + await carddavSync.finalizeCarddavSync(fixture.userId, { + connectionGeneration: fixture.generation, + seenUrls: [fixture.bookAUrl], + status: { + lastSyncAt: '2026-07-10T16:00:00.000Z', + lastError: null, + bookCount: 1, + contactCount: 2, + }, + }); + + const { rows: books } = await databaseClient.query( + `SELECT id, sync_token, remote_sync_revision::text + FROM address_books + WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[fixture.bookA.id, fixture.bookB.id]], + ); + expect(books).toEqual([{ + id: fixture.bookA.id, + sync_token: materializedBook.sync_token, + remote_sync_revision: materializedBook.remote_sync_revision, + }]); + const { rows: [unrelatedAfter] } = await databaseClient.query( + `SELECT id, source, external_url, sync_token, remote_sync_token, + remote_sync_capability, remote_sync_revision::text, + remote_projection_fingerprint + FROM address_books WHERE id = $1`, + [fixture.unrelatedBook.id], + ); + expect(unrelatedAfter).toEqual(unrelatedBefore); + const { rows: objects } = await databaseClient.query( + `SELECT o.href, o.mapping_status, o.legacy_projection, o.local_contact_id, + c.address_book_id AS contact_book_id, + c.display_name, c.vcard, c.etag + FROM carddav_remote_objects o + LEFT JOIN contacts c ON c.id = o.local_contact_id + WHERE o.address_book_id = $1 ORDER BY o.href`, + [fixture.bookA.id], + ); + expect(objects.map(object => ({ + href: object.href, + mapping_status: object.mapping_status, + legacy_projection: object.legacy_projection, + contact_book_id: object.contact_book_id, + }))).toEqual([ + { + href: fixture.aMerge.href, + mapping_status: 'synced', + legacy_projection: null, + contact_book_id: fixture.bookA.id, + }, + { + href: fixture.aSkip.href, + mapping_status: 'synced', + legacy_projection: null, + contact_book_id: fixture.bookA.id, + }, + ]); + const oldTargetIds = new Set([ + fixture.bMergeContact.id, + fixture.bSkipContact.id, + ]); + for (const object of objects) { + expect(oldTargetIds.has(object.local_contact_id)).toBe(false); + const card = object.href === fixture.aMerge.href ? fixture.aMerge : fixture.aSkip; + expect(object.display_name).toBe(card.contact.displayName); + expect(object.vcard).toContain(`FN:${card.contact.displayName}\r\n`); + expect(object.etag).toBe(createHash('md5').update(object.vcard).digest('hex')); + } + const { rows: oldTargets } = await databaseClient.query( + 'SELECT id FROM contacts WHERE id = ANY($1::uuid[])', + [[fixture.bMergeContact.id, fixture.bSkipContact.id]], + ); + expect(oldTargets).toEqual([]); + }, 120_000); + + it('refreshes cached ledger primary email during complete reclassification', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/cached/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-cached-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: [book] } = await databaseClient.query( + `INSERT INTO address_books (user_id, name, source, external_url) + VALUES ($1, 'Cached Source', 'carddav', $2) + RETURNING id`, + [userId, remoteUrl], + ); + const vcard = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:cached-primary', 'FN:Cached Primary', + 'EMAIL:fresh@example.test', 'END:VCARD', '', + ].join('\r\n'); + await databaseClient.query( + `INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, disposition + ) VALUES ($1, $2, '"cached"', $3, 'stale@example.test', 'skip')`, + [book.id, `${remoteUrl}cached.vcf`, vcard], + ); + await beginApply(completePlan({ + userId, + book: { url: remoteUrl, displayName: 'Cached Source' }, + connectionGeneration: generation, + expectedRemoteRevision: '0', + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + upserts: [{ + href: `${remoteUrl}cached.vcf`, + remoteEtag: '"cached"', + vcard, + contact: { + ...remoteCard('cached-primary', 'fresh@example.test').contact, + displayName: 'Cached Primary', + }, + }], + })); + + const { rows: [object] } = await databaseClient.query( + `SELECT primary_email, mapping_status, local_contact_id + FROM carddav_remote_objects WHERE address_book_id = $1`, + [book.id], + ); + expect(object).toMatchObject({ + primary_email: 'fresh@example.test', + mapping_status: 'synced', + local_contact_id: expect.any(String), + }); + }, 120_000); + + it('repairs legacy ledger-only state through complete reclassification', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/ledger-only/`; + const card = { + ...remoteCard('ledger-only', 'ledger-only@example.test'), + href: `${remoteUrl}ledger-only.vcf`, + }; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-ledger-only-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + await beginApply(completePlan({ + userId, + book: { url: remoteUrl, displayName: 'Ledger Only' }, + connectionGeneration: generation, + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + upserts: [card], + })); + const { rows: [before] } = await databaseClient.query( + `SELECT id, sync_token, remote_sync_revision::text + FROM address_books WHERE user_id = $1 AND external_url = $2`, + [userId, remoteUrl], + ); + await databaseClient.query( + `UPDATE carddav_remote_objects SET primary_email = 'stale@example.test' + WHERE address_book_id = $1`, + [before.id], + ); + await beginApply(completePlan({ + userId, + book: { url: remoteUrl, displayName: 'Ledger Only' }, + connectionGeneration: generation, + expectedRemoteRevision: before.remote_sync_revision, + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + upserts: [card], + })); + + const { rows: [after] } = await databaseClient.query( + `SELECT b.sync_token, b.remote_sync_revision::text, o.primary_email + FROM address_books b + JOIN carddav_remote_objects o ON o.address_book_id = b.id + WHERE b.id = $1`, + [before.id], + ); + expect(after).toEqual({ + sync_token: before.sync_token, + remote_sync_revision: String(BigInt(before.remote_sync_revision) + 1n), + primary_email: 'ledger-only@example.test', + }); + }, 120_000); + + it('retains an unmapped explicit contact as an automatic export intent', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/unowned/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-unowned-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: [book] } = await databaseClient.query( + `INSERT INTO address_books (user_id, name, source, external_url) + VALUES ($1, 'Unowned Source', 'carddav', $2) + RETURNING id, sync_token, remote_sync_revision::text`, + [userId, remoteUrl], + ); + const vcard = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:unowned-visible', 'FN:Unowned Visible', + 'EMAIL:unowned@example.test', 'END:VCARD', '', + ].join('\r\n'); + await databaseClient.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, is_auto + ) VALUES ( + $1, $2, 'unowned-visible', $3, $4, 'Unowned Visible', + 'unowned@example.test', $5::jsonb, '[]'::jsonb, false + )`, + [ + book.id, + userId, + vcard, + createHash('md5').update(vcard).digest('hex'), + JSON.stringify([{ + value: 'unowned@example.test', type: 'other', primary: true, + }]), + ], + ); + const applied = await beginApply(completePlan({ + userId, + book: { url: remoteUrl, displayName: 'Unowned Source' }, + connectionGeneration: generation, + expectedRemoteRevision: book.remote_sync_revision, + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + })); + + const { rows: [after] } = await databaseClient.query( + `SELECT sync_token, remote_sync_revision::text, + (SELECT count(*)::int FROM contacts WHERE address_book_id = $1) AS contact_count + FROM address_books WHERE id = $1`, + [book.id], + ); + expect(after).toEqual({ + sync_token: book.sync_token, + remote_sync_revision: '1', + contact_count: 1, + }); + expect(applied).not.toHaveProperty('exports'); + }, 120_000); + + it('preserves a target book inserted while finalization prunes stale remote books', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/footprint/`; + const email = 'expanded-footprint@example.test'; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-footprint-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const card = { + ...remoteCard('expanded-footprint', email), + href: `${remoteUrl}expanded-footprint.vcf`, + }; + await beginApply(completePlan({ + userId, + book: { url: remoteUrl, displayName: 'Footprint Source' }, + connectionGeneration: generation, + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + upserts: [card], + })); + const staleUrl = `https://dav.example.test/addressbooks/${userId}/stale/`; + const { rows: [staleBook] } = await databaseClient.query( + `INSERT INTO address_books (user_id, name, source, external_url) + VALUES ($1, 'Stale Source', 'carddav', $2) RETURNING id`, + [userId, staleUrl], + ); + const booksLocked = deferred(); + const releaseFinalizer = deferred(); + useDatabaseTransactions(async (client, sql, params) => { + const result = await client.query(sql, params); + if (/SELECT id, source, external_url, sync_token[\s\S]+FROM address_books/.test(sql)) { + booksLocked.resolve(); + await releaseFinalizer.promise; + } + return result; + }); + const finalizer = carddavSync.finalizeCarddavSync(userId, { + connectionGeneration: generation, + seenUrls: [remoteUrl], + status: { + lastSyncAt: '2026-07-10T12:00:00.000Z', + lastError: null, + bookCount: 1, + contactCount: 1, + }, + }); + + await booksLocked.promise; + const concurrent = new Client({ connectionString: connectionStringFor(databaseName) }); + await concurrent.connect(); + const { rows: [targetBook] } = await concurrent.query( + `INSERT INTO address_books (user_id, name) + VALUES ($1, 'Concurrent Target') RETURNING id, sync_token`, + [userId], + ); + const targetVcard = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:concurrent-target', 'FN:Concurrent Original', + `EMAIL:${email}`, 'END:VCARD', '', + ].join('\r\n'); + const { rows: [target] } = await concurrent.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, is_auto + ) VALUES ( + $1, $2, 'concurrent-target', $3, $4, 'Concurrent Original', + $5, $6::jsonb, '[]'::jsonb, false + ) RETURNING id`, + [ + targetBook.id, + userId, + targetVcard, + createHash('md5').update(targetVcard).digest('hex'), + email, + JSON.stringify([{ value: email, type: 'other', primary: true }]), + ], + ); + await concurrent.end(); + releaseFinalizer.resolve(); + + await expect(finalizer).resolves.toBe(1); + const { rows: [targetAfter] } = await databaseClient.query( + `SELECT c.display_name, b.sync_token + FROM contacts c JOIN address_books b ON b.id = c.address_book_id + WHERE c.id = $1`, + [target.id], + ); + expect(targetAfter).toEqual({ + display_name: 'Concurrent Original', + sync_token: targetBook.sync_token, + }); + const { rows: [objectAfter] } = await databaseClient.query( + `SELECT disposition, local_contact_id + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 AND o.href = $2`, + [userId, card.href], + ); + expect(objectAfter).toMatchObject({ + disposition: 'separate', + local_contact_id: expect.any(String), + }); + const { rows: staleAfter } = await databaseClient.query( + 'SELECT id FROM address_books WHERE id = $1', + [staleBook.id], + ); + expect(staleAfter).toEqual([]); + }, 120_000); + + it('fences finalization by generation before pruning or status mutation', async () => { + const fixture = await seedLifecycleUser(); + useDatabaseTransactions(); + const before = await persistedLifecycleState(fixture.userId); + + const error = await carddavSync.finalizeCarddavSync(fixture.userId, { + connectionGeneration: 'stale-generation', + seenUrls: [], + status: { + lastSyncAt: '2026-07-10T12:00:00.000Z', + lastError: null, + bookCount: 0, + contactCount: 0, + }, + }).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + expectedConnectionGeneration: 'stale-generation', + actualConnectionGeneration: fixture.generation, + }); + expect(await persistedLifecycleState(fixture.userId)).toEqual(before); + }, 120_000); + + it('disconnects multiple remote books with the exact lifecycle token set', async () => { + const fixture = await seedLifecycleUser(); + useDatabaseTransactions(); + expect(fixture.remoteBooks).toHaveLength(2); + const { rows: seededObjects } = await databaseClient.query( + `SELECT mapping_status, local_contact_id FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 ORDER BY o.href`, + [fixture.userId], + ); + expect(seededObjects).toHaveLength(4); + expect(seededObjects.every(object => object.mapping_status === 'synced')).toBe(true); + expect(new Set(seededObjects.map(object => object.local_contact_id)).size).toBe(4); + const { rows: [beforeLocal] } = await databaseClient.query( + 'SELECT sync_token FROM address_books WHERE id = $1', + [fixture.localBook.id], + ); + + await expect(carddavSync.disconnectCarddavAccount(fixture.userId)) + .resolves.toBe(true); + + const { rows: integrations } = await databaseClient.query( + `SELECT id FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav'`, + [fixture.userId], + ); + expect(integrations).toEqual([]); + const { rows: remoteBooks } = await databaseClient.query( + "SELECT id FROM address_books WHERE user_id = $1 AND source = 'carddav'", + [fixture.userId], + ); + expect(remoteBooks).toEqual([]); + const { rows: [restored] } = await databaseClient.query( + `SELECT display_name, notes FROM contacts WHERE id = $1`, + [fixture.target.id], + ); + expect(restored).toEqual({ + display_name: 'Lifecycle Original', + notes: 'local lifecycle edit', + }); + const { rows: [secondRestored] } = await databaseClient.query( + `SELECT display_name, notes FROM contacts WHERE id = $1`, + [fixture.secondTarget.id], + ); + expect(secondRestored).toEqual({ + display_name: 'Lifecycle Original B', + notes: 'local lifecycle edit B', + }); + const { rows: tokens } = await databaseClient.query( + `SELECT id, sync_token FROM address_books + WHERE id = ANY($1::uuid[]) ORDER BY id`, + [[fixture.localBook.id, fixture.unrelatedBook.id]], + ); + expect(tokens.find(row => row.id === fixture.localBook.id).sync_token) + .toBe(beforeLocal.sync_token); + expect(tokens.find(row => row.id === fixture.unrelatedBook.id).sync_token) + .toBe(fixture.unrelatedBook.sync_token); + const afterFirstDisconnect = await persistedLifecycleState(fixture.userId); + await expect(carddavSync.disconnectCarddavAccount(fixture.userId)) + .resolves.toBe(false); + expect(await persistedLifecycleState(fixture.userId)).toEqual(afterFirstDisconnect); + }, 120_000); + + it('rolls back projection-aware finalization when the status write fails', async () => { + const fixture = await seedLifecycleUser(); + const before = await persistedLifecycleState(fixture.userId); + useDatabaseTransactions(async (client, sql, params) => { + if (/UPDATE user_integrations/.test(sql)) throw new Error('forced finalizer status failure'); + return client.query(sql, params); + }); + + await expect(carddavSync.finalizeCarddavSync(fixture.userId, { + connectionGeneration: fixture.generation, + seenUrls: [], + status: { + lastSyncAt: '2026-07-10T12:00:00.000Z', + lastError: null, + bookCount: 0, + contactCount: 0, + }, + })).rejects.toThrow('forced finalizer status failure'); + expect(await persistedLifecycleState(fixture.userId)).toEqual(before); + }, 120_000); + + it('rolls back projection-aware disconnect when integration deletion fails', async () => { + const fixture = await seedLifecycleUser(); + const before = await persistedLifecycleState(fixture.userId); + useDatabaseTransactions(async (client, sql, params) => { + if (/DELETE FROM user_integrations/.test(sql)) { + throw new Error('forced disconnect deletion failure'); + } + return client.query(sql, params); + }); + + await expect(carddavSync.disconnectCarddavAccount(fixture.userId)) + .rejects.toThrow('forced disconnect deletion failure'); + expect(await persistedLifecycleState(fixture.userId)).toEqual(before); + }, 120_000); + + it('keeps automatic projection ownership idempotent across an empty incremental replay', async () => { + const fixture = await seedAutomaticPair(); + const before = await automaticState(fixture.userId); + const beforeBooks = new Map(before.books.map(book => [book.id, book])); + const sourceBefore = beforeBooks.get(fixture.bookAId); + useDatabaseTransactions(); + + const replay = await beginApply(completePlan({ + userId: fixture.userId, + book: { url: fixture.bookAUrl, displayName: 'Automatic Remote A' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: sourceBefore.remote_sync_revision, + expectedRemoteToken: sourceBefore.remote_sync_token, + nextRemoteToken: 'automatic-replay-token', + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { + observedUrl: fixture.bookAUrl, + canonicalUrl: fixture.bookAUrl, + }, + })); + + expect(replay).toMatchObject({ + changedBookIds: [], + ledgerChanged: false, + updated: 0, + removed: 0, + }); + const after = await automaticState(fixture.userId); + expect(after.contacts).toEqual(before.contacts); + expect(after.ledger).toEqual(before.ledger); + expect(after.integration).toEqual(before.integration); + const afterBooks = new Map(after.books.map(book => [book.id, book])); + expect(afterBooks.get(fixture.bookAId)).toEqual({ + ...sourceBefore, + remote_sync_token: 'automatic-replay-token', + remote_sync_revision: String(BigInt(sourceBefore.remote_sync_revision) + 1n), + }); + for (const id of [fixture.bookBId, fixture.targetBook.id, fixture.unrelatedBook.id]) { + expect(afterBooks.get(id)).toEqual(beforeBooks.get(id)); + } + const { rows: mappings } = await databaseClient.query( + `SELECT href, mapping_status, remote_semantic_hash, local_contact_hash, + legacy_projection, local_contact_id + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + ORDER BY href`, + [fixture.userId], + ); + expect(mappings).toHaveLength(2); + expect(mappings.every(mapping => ( + mapping.mapping_status === 'synced' + && mapping.remote_semantic_hash + && mapping.local_contact_hash + && mapping.legacy_projection === null + && mapping.local_contact_id + ))).toBe(true); + expect(mappings.find(mapping => mapping.href === fixture.duplicate.href).local_contact_id) + .toBe(fixture.targetContact.id); + }, 120_000); + + it('rolls back automatic contact and mapping writes when the book revision update fails', async () => { + const fixture = await seedAutomaticPair(); + const before = await persistedLifecycleState(fixture.userId); + const sourceBefore = before.allBooks.find(book => book.id === fixture.bookBId); + const changed = { + ...fixture.unique, + remoteEtag: '"automatic-rollback"', + vcard: fixture.unique.vcard.replace('FN:Automatic Remote Unique', 'FN:Automatic Rollback'), + contact: { ...fixture.unique.contact, displayName: 'Automatic Rollback' }, + }; + let mappingWritten = false; + + await expect(beginApply(completePlan({ + userId: fixture.userId, + book: { url: fixture.bookBUrl, displayName: 'Automatic Remote B' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: sourceBefore.remote_sync_revision, + expectedRemoteToken: sourceBefore.remote_sync_token, + nextRemoteToken: 'automatic-rollback-token', + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { + observedUrl: fixture.bookBUrl, + canonicalUrl: fixture.bookBUrl, + }, + upserts: [changed], + }), async (client, sql, params) => { + if (mappingWritten && /UPDATE address_books SET/.test(sql)) { + throw new Error('forced automatic projection failure'); + } + const result = await client.query(sql, params); + if (/(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) { + mappingWritten = true; + } + return result; + })).rejects.toThrow('forced automatic projection failure'); + expect(mappingWritten).toBe(true); + expect(await persistedLifecycleState(fixture.userId)).toEqual(before); + }, 120_000); + + it('serializes two NULL-token snapshot plans by revision CAS before projection reads', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const remoteUrl = `https://dav.example.test/addressbooks/${userId}/snapshot-cas/`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-snapshot-cas-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + await databaseClient.query( + `INSERT INTO address_books (user_id, name, source, external_url) + VALUES ($1, 'Snapshot CAS', 'carddav', $2)`, + [userId, remoteUrl], + ); + const winnerCard = { + ...remoteCard('Snapshot Winner', `snapshot-${userId}@example.test`), + href: `${remoteUrl}winner.vcf`, + }; + const loserCard = { + ...remoteCard('Snapshot Loser', `snapshot-${userId}@example.test`), + href: `${remoteUrl}loser.vcf`, + }; + const plan = card => completePlan({ + userId, + book: { url: remoteUrl, displayName: 'Snapshot CAS' }, + connectionGeneration: generation, + expectedRemoteRevision: '0', + expectedRemoteToken: null, + nextRemoteToken: null, + capability: 'snapshot', + collectionIdentity: { observedUrl: remoteUrl, canonicalUrl: remoteUrl }, + upserts: [card], + }); + const firstClient = new Client({ connectionString: connectionStringFor(databaseName) }); + const secondClient = new Client({ connectionString: connectionStringFor(databaseName) }); + await Promise.all([firstClient.connect(), secondClient.connect()]); + await Promise.all([ + firstClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"), + secondClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"), + ]); + const { rows: [{ pid: secondPid }] } = await secondClient.query( + 'SELECT pg_backend_pid() AS pid', + ); + const firstLocked = deferred(); + const releaseFirst = deferred(); + const first = applyWithClient(firstClient, plan(winnerCard), async (client, sql, params) => { + const result = await client.query(sql, params); + if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) { + firstLocked.resolve(); + await releaseFirst.promise; + } + return result; + }); + await firstLocked.promise; + const secondQueries = []; + const second = applyWithClient(secondClient, plan(loserCard), async (client, sql, params) => { + secondQueries.push(sql); + return client.query(sql, params); + }); + await waitForPostgresState({ + description: 'second snapshot transaction to block on the revision lock', + probe: () => probeBackendLock(secondPid), + }); + releaseFirst.resolve(); + + await expect(first).resolves.toMatchObject({ updated: 1 }); + const stale = await second.catch(error => error); + expect(stale).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(stale).toMatchObject({ expectedRemoteRevision: '0', actualRemoteRevision: '1' }); + expect(secondQueries.some(sql => /FROM contacts|FROM carddav_remote_objects/.test(sql))) + .toBe(false); + const afterWinner = await persistedLifecycleState(userId); + const sourceAfterWinner = afterWinner.allBooks.find(book => book.source === 'carddav'); + expect(sourceAfterWinner).toMatchObject({ + remote_sync_token: null, + remote_sync_revision: '1', + }); + expect(afterWinner.contacts).toHaveLength(1); + expect(afterWinner.contacts[0].display_name).toBe(winnerCard.contact.displayName); + + const stableToken = sourceAfterWinner.sync_token; + await applyWithClient(firstClient, { + ...plan(winnerCard), + expectedRemoteRevision: '1', + }); + const afterNoChange = await persistedLifecycleState(userId); + expect(afterNoChange.allBooks.find(book => book.source === 'carddav')).toMatchObject({ + remote_sync_token: null, + remote_sync_revision: '2', + sync_token: stableToken, + }); + expect(afterNoChange.contacts).toEqual(afterWinner.contacts); + expect(afterNoChange.ledger).toEqual(afterWinner.ledger); + await Promise.all([firstClient.end(), secondClient.end()]); + }, 120_000); + + it('rolls back exact state before and after the final remote revision update', async () => { + for (const boundary of ['before-remote-update', 'after-remote-update']) { + const fixture = await seedAutomaticPair(); + const before = await persistedLifecycleState(fixture.userId); + const source = before.allBooks.find(book => book.id === fixture.bookAId); + const changed = { + ...fixture.duplicate, + remoteEtag: `"${boundary}"`, + vcard: fixture.duplicate.vcard.replace( + `FN:${fixture.duplicate.contact.displayName}`, + `FN:${boundary}`, + ), + contact: { ...fixture.duplicate.contact, displayName: boundary }, + }; + const plan = completePlan({ + userId: fixture.userId, + book: { url: source.external_url, displayName: 'Automatic Remote A' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: source.remote_sync_revision, + expectedRemoteToken: source.remote_sync_token, + nextRemoteToken: `${boundary}-token`, + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { + observedUrl: source.external_url, + canonicalUrl: source.external_url, + }, + upserts: [changed], + }); + + await expect(beginApply(plan, async (client, sql, params) => { + if (/external_url = COALESCE\(\$6, external_url\)/.test(sql)) { + if (boundary === 'before-remote-update') throw new Error(boundary); + await client.query(sql, params); + throw new Error(boundary); + } + return client.query(sql, params); + })).rejects.toThrow(boundary); + expect(await persistedLifecycleState(fixture.userId), boundary).toEqual(before); + } + }, 120_000); + + it('updates automatic import photos while preserving a linked explicit contact through unlink', async () => { + const userId = randomUUID(); + const generation = randomUUID(); + const separateUrl = `https://dav.example.test/addressbooks/${userId}/photo-separate/`; + const mergeUrl = `https://dav.example.test/addressbooks/${userId}/photo-merge/`; + const duplicateEmail = `photo-target-${userId}@example.test`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-photo-parity-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: [targetBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'Photo Target') RETURNING id, sync_token", + [userId], + ); + const target = { + uid: `photo-target-${userId}`, + displayName: 'Photo Original', + primaryEmail: duplicateEmail, + emails: [{ value: duplicateEmail, type: 'other', primary: true }], + phones: [], + organization: null, + notes: 'photo local note', + photoData: null, + }; + const targetVcard = generateVCard(target); + const targetEtag = createHash('md5').update(targetVcard).digest('hex'); + const { rows: [targetContact] } = await databaseClient.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, notes, photo_data, is_auto + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, $9, NULL, false + ) RETURNING id`, + [ + targetBook.id, userId, target.uid, targetVcard, targetEtag, + target.displayName, duplicateEmail, JSON.stringify(target.emails), target.notes, + ], + ); + const photoCard = (url, uid, name, email, photoData, remoteEtag) => { + const contact = { + uid, + displayName: name, + primaryEmail: email, + emails: [{ value: email, type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData, + }; + return { + href: `${url}${uid}.vcf`, + remoteEtag, + vcard: generateVCard(contact), + contact, + }; + }; + const jpeg = 'data:image/jpeg;base64,AQID'; + const png = 'data:image/png;base64,BAUG'; + const separateJpeg = photoCard( + separateUrl, 'photo-separate', 'Photo Separate', + `photo-separate-${userId}@example.test`, jpeg, '"photo-separate-1"', + ); + const mergeJpeg = photoCard( + mergeUrl, 'photo-merge', 'Photo Merge', duplicateEmail, jpeg, '"photo-merge-1"', + ); + for (const [url, name, card, token] of [ + [separateUrl, 'Photo Separate', separateJpeg, 'photo-separate-token-1'], + [mergeUrl, 'Photo Merge', mergeJpeg, 'photo-merge-token-1'], + ]) { + await beginApply(completePlan({ + userId, + book: { url, displayName: name }, + connectionGeneration: generation, + nextRemoteToken: token, + capability: 'sync-collection', + collectionIdentity: { observedUrl: url, canonicalUrl: url }, + upserts: [card], + })); + } + const initial = await persistedLifecycleState(userId); + const separateBook = initial.allBooks.find(book => book.external_url === separateUrl); + const mergeBook = initial.allBooks.find(book => book.external_url === mergeUrl); + const initialSeparate = initial.contacts.find(row => row.address_book_id === separateBook.id); + const initialTarget = initial.contacts.find(row => row.address_book_id === targetBook.id); + expect(initialSeparate.photo_data).toBe(jpeg); + expect(initialSeparate.vcard).toContain('PHOTO;ENCODING=b;TYPE=JPEG:AQID\r\n'); + expect(initialSeparate.etag) + .toBe(createHash('md5').update(initialSeparate.vcard).digest('hex')); + expect(initialTarget).toMatchObject({ + display_name: target.displayName, + photo_data: null, + vcard: targetVcard, + etag: targetEtag, + }); + expect(initial.ledger.find(mapping => mapping.href === mergeJpeg.href)).toMatchObject({ + local_contact_id: targetContact.id, + vcard: expect.stringContaining('PHOTO;ENCODING=b;TYPE=JPEG:AQID\r\n'), + }); + + const separatePng = photoCard( + separateUrl, 'photo-separate', 'Photo Separate', separateJpeg.contact.primaryEmail, + png, '"photo-separate-2"', + ); + const mergePng = photoCard( + mergeUrl, 'photo-merge', 'Photo Merge', duplicateEmail, png, '"photo-merge-2"', + ); + for (const [url, card, expectedToken, nextToken] of [ + [separateUrl, separatePng, 'photo-separate-token-1', 'photo-separate-token-2'], + [mergeUrl, mergePng, 'photo-merge-token-1', 'photo-merge-token-2'], + ]) { + await beginApply(completePlan({ + userId, + book: { url, displayName: 'Photo' }, + connectionGeneration: generation, + expectedRemoteRevision: '1', + expectedRemoteToken: expectedToken, + nextRemoteToken: nextToken, + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { observedUrl: url, canonicalUrl: url }, + upserts: [card], + })); + } + const updated = await persistedLifecycleState(userId); + const updatedSeparateBook = updated.allBooks.find(book => book.id === separateBook.id); + const updatedMergeBook = updated.allBooks.find(book => book.id === mergeBook.id); + const updatedTargetBook = updated.allBooks.find(book => book.id === targetBook.id); + const updatedSeparate = updated.contacts.find(row => row.address_book_id === separateBook.id); + const updatedTarget = updated.contacts.find(row => row.address_book_id === targetBook.id); + expect(updatedSeparate.photo_data).toBe(png); + expect(updatedSeparate.vcard).toContain('PHOTO;ENCODING=b;TYPE=PNG:BAUG\r\n'); + expect(updatedSeparate.etag) + .toBe(createHash('md5').update(updatedSeparate.vcard).digest('hex')); + expect(updatedTarget).toEqual(initialTarget); + expect(updated.ledger.find(mapping => mapping.href === mergePng.href)).toMatchObject({ + local_contact_id: targetContact.id, + vcard: expect.stringContaining('PHOTO;ENCODING=b;TYPE=PNG:BAUG\r\n'), + }); + expect(updatedSeparateBook.sync_token).not.toBe(separateBook.sync_token); + expect(updatedMergeBook.sync_token).toBe(mergeBook.sync_token); + expect(updatedTargetBook.sync_token) + .toBe(initial.allBooks.find(book => book.id === targetBook.id).sync_token); + + await beginApply(completePlan({ + userId, + book: { url: mergeUrl, displayName: 'Photo Merge' }, + connectionGeneration: generation, + expectedRemoteRevision: '2', + expectedRemoteToken: 'photo-merge-token-2', + nextRemoteToken: 'photo-merge-token-3', + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { observedUrl: mergeUrl, canonicalUrl: mergeUrl }, + upserts: [mergePng], + })); + const noOp = await persistedLifecycleState(userId); + const noOpTarget = noOp.contacts.find(row => row.address_book_id === targetBook.id); + expect(noOpTarget).toEqual(updatedTarget); + expect(noOp.allBooks.find(book => book.id === targetBook.id).sync_token) + .toBe(updatedTargetBook.sync_token); + expect(noOp.allBooks.find(book => book.id === mergeBook.id)).toMatchObject({ + sync_token: updatedMergeBook.sync_token, + remote_sync_revision: '3', + remote_sync_token: 'photo-merge-token-3', + }); + + await beginApply(completePlan({ + userId, + book: { url: mergeUrl, displayName: 'Photo Merge' }, + connectionGeneration: generation, + expectedRemoteRevision: '3', + expectedRemoteToken: 'photo-merge-token-3', + nextRemoteToken: 'photo-merge-token-4', + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { observedUrl: mergeUrl, canonicalUrl: mergeUrl }, + upserts: [], + removedHrefs: [mergePng.href], + })); + const restored = await persistedLifecycleState(userId); + const restoredTarget = restored.contacts.find(row => row.address_book_id === targetBook.id); + expect(restoredTarget).toEqual(initialTarget); + expect(restored.ledger.some(mapping => mapping.href === mergePng.href)).toBe(false); + expect(restored.allBooks.find(book => book.id === targetBook.id).sync_token) + .toBe(updatedTargetBook.sync_token); + expect(restored.allBooks.find(book => book.id === mergeBook.id).sync_token) + .toBe(updatedMergeBook.sync_token); + }, 120_000); + + it('preserves local edits in both target-lock orderings', async () => { + for (const ordering of ['local-first', 'sync-first']) { + const fixture = await seedAutomaticPair(); + const before = await automaticState(fixture.userId); + const source = before.books.find(book => book.id === fixture.bookAId); + const changed = { + ...fixture.duplicate, + remoteEtag: `"local-edit-${ordering}"`, + vcard: fixture.duplicate.vcard.replace( + `FN:${fixture.duplicate.contact.displayName}`, + `FN:Sync ${ordering}`, + ), + contact: { ...fixture.duplicate.contact, displayName: `Sync ${ordering}` }, + }; + const plan = completePlan({ + userId: fixture.userId, + book: { url: source.external_url, displayName: 'Automatic Remote A' }, + connectionGeneration: fixture.generation, + expectedRemoteRevision: source.remote_sync_revision, + expectedRemoteToken: source.remote_sync_token, + nextRemoteToken: `local-edit-${ordering}-token`, + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { + observedUrl: source.external_url, + canonicalUrl: source.external_url, + }, + upserts: [changed], + }); + const syncClient = new Client({ connectionString: connectionStringFor(databaseName) }); + const localClient = new Client({ connectionString: connectionStringFor(databaseName) }); + await Promise.all([syncClient.connect(), localClient.connect()]); + await Promise.all([ + syncClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"), + localClient.query("SET lock_timeout = '3s'; SET statement_timeout = '6s'"), + ]); + + if (ordering === 'local-first') { + const sourceLocked = deferred(); + const releaseSync = deferred(); + const sync = applyWithClient(syncClient, plan, async (client, sql, params) => { + const result = await client.query(sql, params); + if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) { + sourceLocked.resolve(); + await releaseSync.promise; + } + return result; + }); + await sourceLocked.promise; + const localVcard = generateVCard({ + ...fixture.target, + notes: 'local edit before target lock', + }); + await localClient.query('BEGIN'); + await localClient.query( + `UPDATE address_books SET sync_token = gen_random_uuid()::text + WHERE id = $1`, + [fixture.targetBook.id], + ); + await localClient.query( + `UPDATE contacts SET notes = 'local edit before target lock', + vcard = $2, etag = $3 + WHERE id = $1`, + [ + fixture.targetContact.id, + localVcard, + createHash('md5').update(localVcard).digest('hex'), + ], + ); + await localClient.query('COMMIT'); + releaseSync.resolve(); + await expect(sync).resolves.toMatchObject({ updated: 0, remote: 1 }); + const { rows: [contact] } = await databaseClient.query( + `SELECT display_name, notes, vcard, etag FROM contacts WHERE id = $1`, + [fixture.targetContact.id], + ); + expect(contact).toMatchObject({ + display_name: fixture.target.displayName, + notes: 'local edit before target lock', + vcard: localVcard, + etag: createHash('md5').update(localVcard).digest('hex'), + }); + } else { + const contactsLocked = deferred(); + const releaseSync = deferred(); + const sync = applyWithClient(syncClient, plan, async (client, sql, params) => { + const result = await client.query(sql, params); + if (/SELECT c\.id[\s\S]+c\.uid[\s\S]+FOR UPDATE OF c/.test(sql)) { + contactsLocked.resolve(); + await releaseSync.promise; + } + return result; + }); + await contactsLocked.promise; + const localVcard = generateVCard({ + ...fixture.target, + displayName: 'Local edit after sync lock', + }); + const { rows: [{ pid: localPid }] } = await localClient.query( + 'SELECT pg_backend_pid() AS pid', + ); + const local = (async () => { + await localClient.query('BEGIN'); + try { + await localClient.query( + `UPDATE address_books SET sync_token = gen_random_uuid()::text + WHERE id = $1`, + [fixture.targetBook.id], + ); + await localClient.query( + `UPDATE contacts SET display_name = $2, vcard = $3, etag = $4 + WHERE id = $1`, + [ + fixture.targetContact.id, + 'Local edit after sync lock', + localVcard, + createHash('md5').update(localVcard).digest('hex'), + ], + ); + await localClient.query('COMMIT'); + } catch (error) { + await localClient.query('ROLLBACK'); + throw error; + } + })(); + await waitForPostgresState({ + description: 'local edit transaction to block on the target lock', + probe: () => probeBackendLock(localPid), + }); + releaseSync.resolve(); + await expect(sync).resolves.toMatchObject({ updated: 0, remote: 1 }); + await expect(local).resolves.toBeUndefined(); + const { rows: [contact] } = await databaseClient.query( + `SELECT display_name, vcard, etag FROM contacts WHERE id = $1`, + [fixture.targetContact.id], + ); + expect(contact).toEqual({ + display_name: 'Local edit after sync lock', + vcard: localVcard, + etag: createHash('md5').update(localVcard).digest('hex'), + }); + } + await Promise.all([syncClient.end(), localClient.end()]); + } + }, 120_000); + + it('serializes two automatic source applies without deadlock in both source orders', async () => { + for (const initiation of ['low-source-first', 'high-source-first']) { + const userId = randomUUID(); + const generation = randomUUID(); + const [lowId, highId] = [randomUUID(), randomUUID()].sort(); + const urls = new Map([ + [lowId, `https://dav.example.test/addressbooks/${userId}/c17-low/`], + [highId, `https://dav.example.test/addressbooks/${userId}/c17-high/`], + ]); + const duplicateEmail = `c17-${userId}@example.test`; + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-c17-${initiation}-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + const { rows: [targetBook] } = await databaseClient.query( + "INSERT INTO address_books (user_id, name) VALUES ($1, 'C17 Target') RETURNING id, sync_token", + [userId], + ); + const target = { + uid: `c17-target-${userId}`, + displayName: 'C17 Original', + primaryEmail: duplicateEmail, + emails: [{ value: duplicateEmail, type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + }; + const targetVcard = generateVCard(target); + await databaseClient.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, is_auto + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, false + )`, + [ + targetBook.id, userId, target.uid, targetVcard, + createHash('md5').update(targetVcard).digest('hex'), target.displayName, + duplicateEmail, JSON.stringify(target.emails), + ], + ); + await databaseClient.query( + `INSERT INTO address_books (id, user_id, name, source, external_url) + VALUES ($1, $3, 'C17 Low', 'carddav', $4), + ($2, $3, 'C17 High', 'carddav', $5)`, + [lowId, highId, userId, urls.get(lowId), urls.get(highId)], + ); + const cards = new Map(); + for (const [id, label] of [[lowId, 'low'], [highId, 'high']]) { + const contact = { + ...remoteCard('C17 Initial', duplicateEmail), + href: `${urls.get(id)}${label}.vcf`, + }; + cards.set(id, contact); + await beginApply(completePlan({ + userId, + book: { url: urls.get(id), displayName: `C17 ${label}` }, + connectionGeneration: generation, + nextRemoteToken: `c17-${label}-token-1`, + capability: 'sync-collection', + collectionIdentity: { observedUrl: urls.get(id), canonicalUrl: urls.get(id) }, + upserts: [contact], + })); + } + const before = await automaticState(userId); + const beforeBooks = new Map(before.books.map(book => [book.id, book])); + const applyClients = new Map(); + const applyPids = new Map(); + for (const id of [lowId, highId]) { + const client = new Client({ connectionString: connectionStringFor(databaseName) }); + await client.connect(); + await client.query("SET lock_timeout = '3s'; SET statement_timeout = '8s'"); + applyClients.set(id, client); + const { rows: [{ pid }] } = await client.query('SELECT pg_backend_pid() AS pid'); + applyPids.set(id, pid); + } + const reached = new Map([[lowId, deferred()], [highId, deferred()]]); + const release = deferred(); + const startApply = id => { + const label = id === lowId ? 'low' : 'high'; + const source = beforeBooks.get(id); + return applyWithClient( + applyClients.get(id), + completePlan({ + userId, + book: { url: urls.get(id), displayName: `C17 ${label}` }, + connectionGeneration: generation, + expectedRemoteRevision: source.remote_sync_revision, + expectedRemoteToken: source.remote_sync_token, + nextRemoteToken: `c17-${label}-token-2`, + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { observedUrl: urls.get(id), canonicalUrl: urls.get(id) }, + }), + async (client, sql, params) => { + const result = await client.query(sql, params); + if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) { + reached.get(id).resolve(); + await release.promise; + } + return result; + }, + ); + }; + const order = initiation === 'low-source-first' + ? [lowId, highId] + : [highId, lowId]; + const applies = []; + applies.push(startApply(order[0])); + await reached.get(order[0]).promise; + applies.push(startApply(order[1])); + await waitForPostgresState({ + description: 'second source apply to block on the source lock', + probe: () => probeBackendLock(applyPids.get(order[1])), + }); + + release.resolve(); + const settled = await Promise.allSettled(applies); + expect(settled.every(result => ( + result.status === 'fulfilled' || result.reason?.code !== '40P01' + )), initiation).toBe(true); + expect(settled, initiation).toEqual([ + expect.objectContaining({ status: 'fulfilled' }), + expect.objectContaining({ status: 'fulfilled' }), + ]); + + const after = await automaticState(userId); + expect(after.integration).toEqual(before.integration); + expect(after.contacts).toEqual(before.contacts); + expect(after.ledger).toEqual(before.ledger); + for (const id of [lowId, highId]) { + const sourceBefore = beforeBooks.get(id); + const sourceAfter = after.books.find(book => book.id === id); + const label = id === lowId ? 'low' : 'high'; + expect(sourceAfter).toMatchObject({ + remote_sync_token: `c17-${label}-token-2`, + remote_sync_revision: String(BigInt(sourceBefore.remote_sync_revision) + 1n), + sync_token: sourceBefore.sync_token, + }); + } + await Promise.all([...applyClients.values()].map(client => client.end())); + } + }, 120_000); + + it('blocks a later automatic apply and rejects its stale revision after the winner commits', async () => { + vi.clearAllMocks(); + const userId = randomUUID(); + const generation = randomUUID(); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-automatic-lock-${userId}`], + ); + await databaseClient.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', jsonb_build_object( + 'connectionGeneration', $2::text + ))`, + [userId, generation], + ); + await beginApply(completePlan({ + userId, + connectionGeneration: generation, + nextRemoteToken: 'automatic-token-before', + })); + const { rows: [sourceBook] } = await databaseClient.query( + `SELECT remote_sync_revision::text + FROM address_books + WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`, + [userId, BOOK_URL], + ); + + const laterClient = new Client({ connectionString: connectionStringFor(databaseName) }); + await laterClient.connect(); + const { rows: [{ pid: laterPid }] } = await laterClient.query( + 'SELECT pg_backend_pid() AS pid', + ); + + const applyHoldingLocks = deferred(); + const releaseApply = deferred(); + const applyPromise = beginApply(completePlan({ + userId, + connectionGeneration: generation, + expectedRemoteRevision: sourceBook.remote_sync_revision, + expectedRemoteToken: 'automatic-token-before', + nextRemoteToken: 'automatic-token-after', + replaceAll: false, + }), async (client, sql, params) => { + const result = await client.query(sql, params); + if (/FROM user_integrations[\s\S]+FOR UPDATE/.test(sql)) { + applyHoldingLocks.resolve(); + await releaseApply.promise; + } + return result; + }); + + await applyHoldingLocks.promise; + const laterPromise = applyWithClient(laterClient, completePlan({ + userId, + connectionGeneration: generation, + expectedRemoteRevision: sourceBook.remote_sync_revision, + expectedRemoteToken: 'automatic-token-before', + nextRemoteToken: 'later-automatic-token', + replaceAll: false, + })); + await waitForPostgresState({ + description: 'later automatic apply to block behind the integration lock', + probe: () => probeBackendLock(laterPid), + }); + releaseApply.resolve(); + + await expect(applyPromise).resolves.toMatchObject({ remote: 0 }); + await expect(laterPromise).rejects.toMatchObject({ + name: 'StaleCarddavPlanError', + expectedRemoteRevision: sourceBook.remote_sync_revision, + actualRemoteRevision: String(BigInt(sourceBook.remote_sync_revision) + 1n), + }); + const { rows: [book] } = await databaseClient.query( + `SELECT remote_sync_token, remote_sync_revision::text + FROM address_books + WHERE user_id = $1 AND source = 'carddav' AND external_url = $2`, + [userId, BOOK_URL], + ); + expect(book).toEqual({ + remote_sync_token: 'automatic-token-after', + remote_sync_revision: String(BigInt(sourceBook.remote_sync_revision) + 1n), + }); + await laterClient.end(); + }, 120_000); + +}); diff --git a/backend/src/services/carddavSync.integration.test.js b/backend/src/services/carddavSync.integration.test.js new file mode 100644 index 00000000..85fc9564 --- /dev/null +++ b/backend/src/services/carddavSync.integration.test.js @@ -0,0 +1,4281 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import bcrypt from 'bcryptjs'; +import express from 'express'; +import pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createCarddavFixtureServer } from './carddavFixtureServer.js'; +import { + applyTestMigrations, + assertMinimumPostgresVersion, + createTestDatabase, + dropTestDatabase, + postgresTestContext, + productionDatabaseEnvironment, + waitForPostgresState, +} from './postgresTestHelpers.js'; +import { + deleteCardResource, + discoverAddressBooks, + fetchAddressBookDelta, + fetchCardResource, + putCardResource, +} from './carddavClient.js'; +import { generateVCard, parseVCard } from '../utils/vcard.js'; +import { + localContactHash, + parseVCardDocument, + presentedEtag, + presentedVCard, + semanticVCardHash, +} from '../utils/vcardProperties.js'; + +const credentials = { + username: 'fixture-user', + password: 'fixture-password', + allowPrivate: true, +}; + +function vcard(uid, name) { + return [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${uid}`, + `FN:${name}`, + `EMAIL:${uid}@example.test`, + 'END:VCARD', + ].join('\n'); +} + +describe('CardDAV sync against a real HTTP fixture', () => { + let fixture; + let book; + + beforeAll(async () => { + fixture = createCarddavFixtureServer(); + await fixture.listen(); + [book] = await discoverAddressBooks({ serverUrl: fixture.serverUrl, ...credentials }); + }); + + afterAll(async () => { + await fixture?.close(); + }); + + it('exercises initial, incremental, recovery, paging, and snapshot plans', async () => { + const alpha = fixture.href('alpha.vcf'); + const beta = fixture.href('beta.vcf'); + const gamma = fixture.href('gamma.vcf'); + const delta = fixture.href('delta.vcf'); + const collectionIdentity = { + observedUrl: book.url, + canonicalUrl: book.url, + }; + + fixture.putContact(alpha, '"alpha-1"', vcard('alpha', 'Alpha One')); + fixture.queueSync('', { + events: [{ href: alpha, etag: '"alpha-1"' }], + nextToken: '00123', + }); + const initial = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: null, + }); + expect(initial).toEqual({ + expectedRemoteToken: null, + nextRemoteToken: '00123', + capability: 'sync-collection', + replaceAll: true, + upserts: [{ href: alpha, etag: '"alpha-1"', vcard: vcard('alpha', 'Alpha One') }], + removedHrefs: [], + collectionIdentity, + }); + + fixture.queueSync('00123', { + events: [], + nextToken: 'opaque:2/unchanged', + }); + const unchanged = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: '00123', + }); + expect(unchanged).toEqual({ + expectedRemoteToken: '00123', + nextRemoteToken: 'opaque:2/unchanged', + capability: 'sync-collection', + replaceAll: false, + upserts: [], + removedHrefs: [], + collectionIdentity, + }); + expect(fixture.counters.multiget).toBe(1); + + fixture.putContact(beta, '"beta-1"', vcard('beta', 'Beta One')); + fixture.queueSync('opaque:2/unchanged', { + events: [{ href: beta, etag: '"beta-1"' }], + nextToken: 'opaque:3/add', + }); + const added = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: 'opaque:2/unchanged', + }); + expect(added).toEqual({ + expectedRemoteToken: 'opaque:2/unchanged', + nextRemoteToken: 'opaque:3/add', + capability: 'sync-collection', + replaceAll: false, + upserts: [{ href: beta, etag: '"beta-1"', vcard: vcard('beta', 'Beta One') }], + removedHrefs: [], + collectionIdentity, + }); + + fixture.putContact(alpha, '"alpha-2"', vcard('alpha', 'Alpha Two')); + fixture.queueSync('opaque:3/add', { + events: [{ href: alpha, etag: '"alpha-2"' }], + nextToken: 'opaque:4/edit', + }); + const edited = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: 'opaque:3/add', + }); + expect(edited).toEqual({ + expectedRemoteToken: 'opaque:3/add', + nextRemoteToken: 'opaque:4/edit', + capability: 'sync-collection', + replaceAll: false, + upserts: [{ href: alpha, etag: '"alpha-2"', vcard: vcard('alpha', 'Alpha Two') }], + removedHrefs: [], + collectionIdentity, + }); + + fixture.deleteContact(beta); + fixture.queueSync('opaque:4/edit', { + events: [{ href: beta, status: 404 }], + nextToken: 'opaque:5/delete', + }); + const deleted = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: 'opaque:4/edit', + }); + expect(deleted).toEqual({ + expectedRemoteToken: 'opaque:4/edit', + nextRemoteToken: 'opaque:5/delete', + capability: 'sync-collection', + replaceAll: false, + upserts: [], + removedHrefs: [beta], + collectionIdentity, + }); + + fixture.putContact(gamma, '"gamma-1"', vcard('gamma', 'Gamma One')); + fixture.putContact(delta, '"delta-1"', vcard('delta', 'Delta One')); + fixture.queueSync('opaque:5/delete', { + events: [{ href: gamma, etag: '"gamma-1"' }], + nextToken: 'opaque:page-2', + truncated: true, + }); + fixture.queueSync('opaque:page-2', { + events: [{ href: delta, etag: '"delta-1"' }], + nextToken: 'opaque:6/paged', + }); + const paged = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: 'opaque:5/delete', + }); + expect(paged).toEqual({ + expectedRemoteToken: 'opaque:5/delete', + nextRemoteToken: 'opaque:6/paged', + capability: 'sync-collection', + replaceAll: false, + upserts: [ + { href: gamma, etag: '"gamma-1"', vcard: vcard('gamma', 'Gamma One') }, + { href: delta, etag: '"delta-1"', vcard: vcard('delta', 'Delta One') }, + ], + removedHrefs: [], + collectionIdentity, + }); + + fixture.queueSync('opaque:invalid', { + status: 403, + precondition: 'valid-sync-token', + }); + await expect(fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: 'opaque:invalid', + })).rejects.toMatchObject({ + name: 'CardDavError', + status: 403, + precondition: 'valid-sync-token', + }); + fixture.queueSync('', { + events: [ + { href: alpha, etag: '"alpha-2"' }, + { href: gamma, etag: '"gamma-1"' }, + { href: delta, etag: '"delta-1"' }, + ], + nextToken: 'opaque:7/reconciled', + }); + const reconciled = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: '', + }); + expect(reconciled).toEqual({ + expectedRemoteToken: '', + nextRemoteToken: 'opaque:7/reconciled', + capability: 'sync-collection', + replaceAll: true, + upserts: [ + { href: alpha, etag: '"alpha-2"', vcard: vcard('alpha', 'Alpha Two') }, + { href: gamma, etag: '"gamma-1"', vcard: vcard('gamma', 'Gamma One') }, + { href: delta, etag: '"delta-1"', vcard: vcard('delta', 'Delta One') }, + ], + removedHrefs: [], + collectionIdentity, + }); + + fixture.queueSync('opaque:7/reconciled', { status: 405 }); + const snapshot = await fetchAddressBookDelta({ + ...book, + ...credentials, + syncToken: 'opaque:7/reconciled', + }); + expect(snapshot).toEqual({ + expectedRemoteToken: 'opaque:7/reconciled', + nextRemoteToken: null, + capability: 'snapshot', + replaceAll: true, + upserts: [ + { href: alpha, etag: '"alpha-2"', vcard: vcard('alpha', 'Alpha Two') }, + { href: delta, etag: '"delta-1"', vcard: vcard('delta', 'Delta One') }, + { href: gamma, etag: '"gamma-1"', vcard: vcard('gamma', 'Gamma One') }, + ], + removedHrefs: [], + collectionIdentity, + }); + + expect(fixture.counters).toMatchObject({ + requests: 19, + propfind: 3, + sync: 10, + multiget: 5, + addressbookQuery: 1, + requestUri507: 1, + snapshotFilters: [1], + syncTokens: [ + '', + '00123', + 'opaque:2/unchanged', + 'opaque:3/add', + 'opaque:4/edit', + 'opaque:5/delete', + 'opaque:page-2', + 'opaque:invalid', + '', + 'opaque:7/reconciled', + ], + multigetSizes: [1, 1, 1, 2, 3], + }); + expect(initial.nextRemoteToken).toBe('00123'); + expect(fixture.counters.syncTokens).toContain('00123'); + const fixtureOrigin = new URL(fixture.serverUrl).origin; + expect(fixture.requests.filter(request => request.authorization) + .every(request => request.origin === fixtureOrigin)).toBe(true); + expect(fixture.requests.every(request => request.authorization === 'Basic Zml4dHVyZS11c2VyOmZpeHR1cmUtcGFzc3dvcmQ=')).toBe(true); + expect(fixture.requests.filter(request => request.method === 'REPORT') + .every(request => request.depth === '0' || request.depth === '1')).toBe(true); + }); + + it('discovers writable metadata and enforces conditional resource writes', async () => { + fixture.reset(); + expect(book).toMatchObject({ + capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' }, + discoveryIndex: 0, + addressData: [ + { contentType: 'text/vcard', version: '4.0' }, + { contentType: 'text/vcard', version: '3.0' }, + ], + }); + const href = fixture.href('conditional.vcf'); + const createdCard = vcard('conditional', 'Conditional Create'); + + const created = await putCardResource({ + ...credentials, + url: book.url, + href, + vcard: createdCard, + }); + expect(created).toEqual({ href, etag: expect.any(String) }); + await expect(fetchCardResource({ + ...credentials, + url: book.url, + href, + })).resolves.toEqual({ href, etag: created.etag, vcard: createdCard }); + + await expect(putCardResource({ + ...credentials, + url: book.url, + href, + etag: '"stale"', + vcard: vcard('conditional', 'Rejected Update'), + })).rejects.toMatchObject({ operation: 'update', status: 412 }); + + fixture.queueWrite('PUT', { status: 423, rawBody: 'locked' }); + await expect(putCardResource({ + ...credentials, + url: book.url, + href: fixture.href('locked.vcf'), + vcard: vcard('locked', 'Locked Create'), + })).rejects.toMatchObject({ operation: 'create', status: 423 }); + + const updatedCard = vcard('conditional', 'Conditional Update'); + const updated = await putCardResource({ + ...credentials, + url: book.url, + href, + etag: created.etag, + vcard: updatedCard, + }); + await expect(fetchCardResource({ + ...credentials, + url: book.url, + href, + })).resolves.toEqual({ href, etag: updated.etag, vcard: updatedCard }); + + await expect(deleteCardResource({ + ...credentials, + url: book.url, + href, + etag: updated.etag, + })).resolves.toEqual({ href }); + await expect(deleteCardResource({ + ...credentials, + url: book.url, + href, + etag: updated.etag, + })).resolves.toEqual({ href }); + + const resourceRequests = fixture.requests.filter(request => ( + request.method === 'GET' || request.method === 'PUT' || request.method === 'DELETE' + )); + expect(resourceRequests.map(request => ({ + method: request.method, + accept: request.accept, + contentType: request.contentType, + ifMatch: request.ifMatch, + ifNoneMatch: request.ifNoneMatch, + }))).toEqual([ + { + method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8', + ifMatch: undefined, ifNoneMatch: '*', + }, + { + method: 'GET', accept: 'text/vcard', contentType: undefined, + ifMatch: undefined, ifNoneMatch: undefined, + }, + { + method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8', + ifMatch: '"stale"', ifNoneMatch: undefined, + }, + { + method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8', + ifMatch: undefined, ifNoneMatch: '*', + }, + { + method: 'PUT', accept: '*/*', contentType: 'text/vcard; charset=utf-8', + ifMatch: created.etag, ifNoneMatch: undefined, + }, + { + method: 'GET', accept: 'text/vcard', contentType: undefined, + ifMatch: undefined, ifNoneMatch: undefined, + }, + { + method: 'DELETE', accept: '*/*', contentType: undefined, + ifMatch: updated.etag, ifNoneMatch: undefined, + }, + { + method: 'DELETE', accept: '*/*', contentType: undefined, + ifMatch: updated.etag, ifNoneMatch: undefined, + }, + ]); + }); + + it('rejects a resource redirect before the sibling endpoint receives the write', async () => { + fixture.reset(); + const sourceHref = fixture.href('redirect-source.vcf'); + const sourcePath = new URL(sourceHref).pathname; + const siblingPath = '/addressbooks/fixture-user/sibling/redirect-target.vcf'; + const siblingHref = new URL(siblingPath, fixture.serverUrl).href; + fixture.queueRedirect('PUT', sourcePath, siblingPath, 307); + + try { + await expect(putCardResource({ + ...credentials, + url: book.url, + href: sourceHref, + vcard: vcard('redirect-source', 'Redirect Source'), + })).rejects.toMatchObject({ + code: 'ERR_DAV_HREF_SCOPE', + operation: 'create', + }); + + expect(fixture.requests.filter(request => request.path === sourcePath)).toHaveLength(1); + expect(fixture.requests.filter(request => request.path === siblingPath)).toHaveLength(0); + } finally { + fixture.deleteContact(siblingHref); + } + }); +}); + +const { databaseUrl, connectionStringFor } = postgresTestContext( + 'CardDAV production integration tests', +); + +const { Client } = pg; +const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations'); +const databaseName = `carddav_sync_e2e_${process.pid}_${randomUUID().replaceAll('-', '').slice(0, 12)}`; +const encryptionKey = '11'.repeat(32); +const productionEnvironment = productionDatabaseEnvironment(encryptionKey); +let adminClient; +let databaseClient; +let productionDb; +let carddavSync; +let carddavContactService; +let carddavConflictService; +let carddavConflictsRouter; +let encrypt; + +async function applyMigrations(client) { + await applyTestMigrations(client, { migrationsDirectory }); +} + +async function projectionState(userId, { timestamps = false } = {}) { + const { rows: books } = await databaseClient.query(` + SELECT id, source, external_url, sync_token, remote_sync_token, + remote_sync_capability, remote_sync_revision::text, + remote_projection_fingerprint, created_at, updated_at + FROM address_books + WHERE user_id = $1 + ORDER BY source, external_url NULLS FIRST, id + `, [userId]); + const { rows: contacts } = await databaseClient.query(` + SELECT id, address_book_id, user_id, uid, vcard, etag, display_name, + first_name, last_name, primary_email, emails, phones, organization, + notes, photo_data, created_at, updated_at + FROM contacts + WHERE user_id = $1 + ORDER BY address_book_id, uid + `, [userId]); + const { rows: ledger } = await databaseClient.query(` + SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, o.primary_email, + o.local_contact_id, + o.created_at, o.updated_at + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + ORDER BY o.address_book_id, o.href + `, [userId]); + if (timestamps) return { books, contacts, ledger }; + const strip = rows => rows.map(row => { + const stable = { ...row }; + delete stable.created_at; + delete stable.updated_at; + return stable; + }); + return { books: strip(books), contacts: strip(contacts), ledger: strip(ledger) }; +} + +async function integrationState(userId, { timestamps = false } = {}) { + const { rows } = await databaseClient.query(` + SELECT id, provider, config, updated_at + FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + `, [userId]); + if (timestamps) return rows; + return rows.map(row => { + const stable = { ...row }; + delete stable.updated_at; + return stable; + }); +} + +async function failureBoundaryState(userId) { + const projection = await projectionState(userId, { timestamps: true }); + const { rows: mappings } = await databaseClient.query(` + SELECT mapping.address_book_id, mapping.href, mapping.remote_etag, + mapping.vcard, mapping.primary_email, mapping.local_contact_id, + mapping.mapping_status, mapping.vcard_version, + mapping.remote_semantic_hash, mapping.local_contact_hash, + mapping.mapping_revision::text, mapping.pending_operation, + mapping.pending_vcard, mapping.pending_local_hash, + mapping.pending_remote_semantic_hash, mapping.pending_started_at, + mapping.created_at, mapping.updated_at + FROM carddav_remote_objects mapping + JOIN address_books book ON book.id = mapping.address_book_id + WHERE book.user_id = $1 + ORDER BY mapping.address_book_id, mapping.href + `, [userId]); + const { rows: conflicts } = await databaseClient.query(` + SELECT id, address_book_id, href, user_id, base_local_hash, remote_etag, + local_vcard, remote_vcard, local_tombstone, remote_tombstone, + status, resolution, resolved_by, resolved_at, created_at, updated_at + FROM carddav_conflicts + WHERE user_id = $1 + ORDER BY address_book_id, href + `, [userId]); + return { + projection, + mappings, + conflicts, + integrations: await integrationState(userId, { timestamps: true }), + }; +} + +async function seedConnectedUser(fixture, overrides = {}) { + const userId = randomUUID(); + const connectionGeneration = overrides.connectionGeneration || randomUUID(); + const password = overrides.password || 'fixture-password'; + const encryptedPassword = encrypt(password); + await databaseClient.query( + 'INSERT INTO users (id, username) VALUES ($1, $2)', + [userId, `carddav-e2e-${userId}`], + ); + const config = { + serverUrl: fixture.serverUrl, + username: overrides.username || 'fixture-user', + password: encryptedPassword, + intervalMin: 60, + connectionGeneration, + lastError: 'seeded-error', + bookCount: 0, + contactCount: 0, + }; + await databaseClient.query(` + INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb) + `, [userId, JSON.stringify(config)]); + return { userId, config, connectionGeneration, encryptedPassword, password }; +} + +function remoteVcard(uid, name, email = `${uid}@example.test`) { + return [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${uid}`, + `FN:${name}`, + `EMAIL:${email}`, + 'END:VCARD', + ].join('\n'); +} + +function remotePhotoVcard(uid, name, email, photoLine) { + return [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${uid}`, + `FN:${name}`, + `EMAIL:${email}`, + photoLine, + 'END:VCARD', + ].join('\n'); +} + +function expectedLocalContact(href, vcard, bookId, userId, id) { + const parsed = parseVCard(vcard); + const uid = createHash('sha256').update(href).digest('hex'); + const localVcard = generateVCard({ ...parsed, uid }); + return { + id, + address_book_id: bookId, + user_id: userId, + uid, + vcard: localVcard, + etag: createHash('md5').update(localVcard).digest('hex'), + display_name: parsed.displayName, + first_name: parsed.firstName, + last_name: parsed.lastName, + primary_email: parsed.emails[0].value, + emails: parsed.emails, + phones: parsed.phones, + organization: parsed.organization, + notes: parsed.notes, + photo_data: parsed.photoData, + }; +} + +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function listenOnLocalhost(app) { + return new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => resolve(server)); + server.once('error', reject); + }); +} + +async function closeServer(server) { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => ( + error ? reject(error) : resolve() + ))); +} + +async function listenConflictApi() { + const app = express(); + app.use(express.json()); + app.use((request, response, next) => { + request.session = { userId: request.get('X-Test-User-Id') }; + next(); + }); + app.use('/conflicts', carddavConflictsRouter); + return listenOnLocalhost(app); +} + +async function seedSingleRemoteContact(fixture, userId, { + uid = 'seeded', + name = 'Seeded Contact', + etag = '"seeded-1"', + token = 'seeded-token', +} = {}) { + const href = fixture.href(`${uid}.vcf`); + const card = remoteVcard(uid, name); + fixture.putContact(href, etag, card); + fixture.queueSync('', { events: [{ href, etag }], nextToken: token }); + const result = await carddavSync.syncUser(userId); + expect(result).toMatchObject({ ok: true, bookCount: 1, contactCount: 1 }); + return { href, card, etag, token }; +} + +async function seedMappedExplicitContact(fixture, userId) { + const uid = randomUUID(); + const contact = { + uid, + displayName: 'Retry After Before', + firstName: 'Retry', + lastName: 'After Before', + emails: [{ value: 'retry-after@example.test', type: 'work', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [], + }; + const card = generateVCard(contact); + const href = fixture.href(`${uid}.vcf`); + const remoteEtag = '"retry-after-1"'; + const { rows: [localBook] } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Retry After Local') + RETURNING id + `, [userId]); + const { rows: [remoteBook] } = await databaseClient.query(` + INSERT INTO address_books ( + user_id, name, source, external_url, + remote_create_capability, remote_update_capability, remote_delete_capability + ) VALUES ($1, 'Retry After Remote', 'carddav', $2, 'allowed', 'allowed', 'allowed') + RETURNING id + `, [userId, fixture.href('')]); + const { rows: [row] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + last_name, primary_email, emails, phones, additional_fields, is_auto + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '[]'::jsonb, + '[]'::jsonb, false + ) + RETURNING id + `, [ + localBook.id, + userId, + uid, + card, + createHash('md5').update(card).digest('hex'), + contact.displayName, + contact.firstName, + contact.lastName, + contact.emails[0].value, + JSON.stringify(contact.emails), + ]); + await databaseClient.query(` + INSERT INTO carddav_remote_objects ( + address_book_id, href, remote_etag, vcard, primary_email, + local_contact_id, mapping_status, vcard_version, + remote_semantic_hash, local_contact_hash, last_synced_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'synced', '3.0', $7, $8, NOW()) + `, [ + remoteBook.id, + href, + remoteEtag, + card, + contact.emails[0].value, + row.id, + semanticVCardHash(parseVCardDocument(card)), + localContactHash(contact), + ]); + fixture.putContact(href, remoteEtag, card); + return { ...row, uid, href }; +} + +async function seedUnmappedExplicitContact(userId, { + uid = randomUUID(), + displayName = 'Unmapped Explicit', +} = {}) { + const contact = { + uid, + displayName, + emails: [{ value: `${uid}@example.test`, type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [], + }; + const card = generateVCard(contact); + const { rows: [book] } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Unmapped Explicit Local') RETURNING id + `, [userId]); + const { rows: [row] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, additional_fields, is_auto + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, '[]'::jsonb, false + ) + RETURNING id + `, [ + book.id, + userId, + uid, + card, + createHash('md5').update(card).digest('hex'), + displayName, + contact.emails[0].value, + JSON.stringify(contact.emails), + ]); + return { ...row, uid, card }; +} + +async function seedResolutionConflict(fixture, userId, { + uid, + remoteCard, + remoteEtag = `"${uid}-2"`, + remoteTombstone = false, +}) { + const initial = await seedSingleRemoteContact(fixture, userId, { + uid, + name: `${uid} Initial`, + token: `${uid}-token`, + }); + const { rows: [mapping] } = await databaseClient.query(` + SELECT mapping.address_book_id, mapping.href, mapping.local_contact_id, + mapping.local_contact_hash, mapping.mapping_revision::text, + contact.vcard AS local_vcard, contact.display_name AS local_display_name + FROM carddav_remote_objects mapping + JOIN contacts contact ON contact.id = mapping.local_contact_id + WHERE mapping.href = $1 AND contact.user_id = $2 + `, [initial.href, userId]); + const { rows: [conflictedMapping] } = await databaseClient.query(` + UPDATE carddav_remote_objects + SET mapping_status = 'conflict', mapping_revision = mapping_revision + 1, + updated_at = NOW() + WHERE address_book_id = $1 AND href = $2 + RETURNING mapping_revision::text + `, [mapping.address_book_id, mapping.href]); + if (remoteTombstone) fixture.deleteContact(initial.href); + else fixture.putContact(initial.href, remoteEtag, remoteCard); + const { rows: [conflict] } = await databaseClient.query(` + INSERT INTO carddav_conflicts ( + address_book_id, href, user_id, base_local_hash, remote_etag, + local_vcard, remote_vcard, local_tombstone, remote_tombstone + ) VALUES ($1,$2,$3,$4,$5,$6,$7,false,$8) + RETURNING id + `, [ + mapping.address_book_id, + mapping.href, + userId, + mapping.local_contact_hash, + remoteTombstone ? null : remoteEtag, + mapping.local_vcard, + remoteTombstone ? null : remoteCard, + remoteTombstone, + ]); + return { + ...mapping, + mappingRevision: conflictedMapping.mapping_revision, + conflictId: conflict.id, + initial, + }; +} + +async function seedTwoRemoteBooks(fixture, userId) { + const bookAPath = '/addressbooks/fixture-user/contacts-a/'; + const bookBPath = '/addressbooks/fixture-user/contacts-b/'; + const bookAUrl = new URL(bookAPath, fixture.serverUrl).href; + const bookBUrl = new URL(bookBPath, fixture.serverUrl).href; + const hrefA = new URL('person-a.vcf', bookAUrl).href; + const hrefB = new URL('person-b.vcf', bookBUrl).href; + const cardA = remoteVcard('person-a', 'Person A'); + const cardB = remoteVcard('person-b', 'Person B'); + fixture.queueDiscovery({ books: [ + { href: bookAPath, displayName: 'Contacts A' }, + { href: bookBPath, displayName: 'Contacts B' }, + ] }); + fixture.putContact(hrefA, '"person-a-1"', cardA); + fixture.putContact(hrefB, '"person-b-1"', cardB); + fixture.queueSync('', { + events: [{ href: hrefA, etag: '"person-a-1"' }], + nextToken: 'two-book-a-1', + }); + fixture.queueSync('', { + events: [{ href: hrefB, etag: '"person-b-1"' }], + nextToken: 'two-book-b-1', + }); + expect(await carddavSync.syncUser(userId)).toMatchObject({ + ok: true, bookCount: 2, contactCount: 2, + }); + const state = await projectionState(userId); + return { + bookAPath, + bookBPath, + bookAUrl, + bookBUrl, + hrefA, + hrefB, + cardA, + cardB, + bookA: state.books.find(book => book.external_url === bookAUrl), + bookB: state.books.find(book => book.external_url === bookBUrl), + }; +} + +describe('production CardDAV HTTP to PostgreSQL 16', () => { + beforeAll(async () => { + adminClient = new Client({ connectionString: databaseUrl }); + await adminClient.connect(); + await createTestDatabase(adminClient, databaseName); + const connectionString = connectionStringFor(databaseName); + databaseClient = new Client({ connectionString }); + await databaseClient.connect(); + await assertMinimumPostgresVersion(databaseClient); + await applyMigrations(databaseClient); + await databaseClient.query(` + INSERT INTO system_settings (key, value) VALUES ('allow_private_hosts', 'true') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `); + productionEnvironment.configure(connectionString); + productionDb = await import('./db.js'); + productionEnvironment.configurePool(productionDb.pool); + carddavSync = await import('./carddavSync.js'); + carddavContactService = await import('./carddavContactService.js'); + carddavConflictService = await import('./carddavConflictService.js'); + ({ default: carddavConflictsRouter } = await import('../routes/carddavConflicts.js')); + ({ encrypt } = await import('./encryption.js')); + }, 120_000); + + afterAll(async () => { + await productionDb?.pool.end(); + await databaseClient?.end(); + if (adminClient) { + await dropTestDatabase(adminClient, databaseName); + await adminClient.end(); + } + productionEnvironment.restore(); + }, 120_000); + + it('schedules a valid Retry-After after 429 without replaying or changing confirmed state', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const contact = await seedMappedExplicitContact(fixture, seeded.userId); + const before = await failureBoundaryState(seeded.userId); + fixture.reset(); + fixture.queueWrite('PUT', { + status: 429, + headers: { 'Retry-After': '120' }, + }); + + const startedAt = Date.now(); + const error = await carddavContactService.updateContact( + seeded.userId, + contact.id, + { displayName: 'Retry After Attempted' }, + ).catch(value => value); + const finishedAt = Date.now(); + const afterThrottle = await failureBoundaryState(seeded.userId); + const retryAfterAt = afterThrottle.integrations[0].config.retryAfterAt; + + expect.soft(error).toMatchObject({ + name: 'CardDavError', + operation: 'update', + status: 429, + retryAfterAt, + }); + expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 120_000); + expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 120_000); + expect.soft(afterThrottle.projection).toEqual(before.projection); + expect.soft(afterThrottle.mappings).toEqual(before.mappings); + expect.soft(afterThrottle.conflicts).toEqual(before.conflicts); + expect.soft(afterThrottle.integrations).toEqual([{ + ...before.integrations[0], + config: { ...before.integrations[0].config, retryAfterAt }, + updated_at: expect.any(Date), + }]); + expect.soft(afterThrottle.integrations[0].updated_at.getTime()) + .toBeGreaterThanOrEqual(before.integrations[0].updated_at.getTime()); + expect.soft(fixture.requests.map(request => request.method)).toEqual(['PUT']); + + const immediateMutation = await carddavContactService.updateContact( + seeded.userId, + contact.id, + { displayName: 'Retry After Attempted Again' }, + ).catch(value => value); + const immediateSync = await carddavSync.syncUser(seeded.userId); + + expect.soft(immediateMutation).toMatchObject({ + name: 'CardDavError', + operation: 'update', + status: 429, + retryAfterAt, + }); + expect.soft(immediateSync).toMatchObject({ + ok: false, + retryAfterAt, + }); + expect.soft(fixture.requests.map(request => request.method)).toEqual(['PUT']); + expect.soft(await failureBoundaryState(seeded.userId)).toEqual(afterThrottle); + + await databaseClient.query(` + UPDATE user_integrations + SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text)), + updated_at = NOW() + WHERE user_id = $1 AND provider = 'carddav' + `, [seeded.userId, '2000-01-01T00:00:00.000Z']); + fixture.reset(); + fixture.queueSync('', { + events: [{ href: contact.href, etag: '"retry-after-1"' }], + nextToken: 'retry-after-cleared', + }); + + expect.soft(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + expect.soft((await integrationState(seeded.userId))[0].config.retryAfterAt).toBeNull(); + } finally { + await fixture.close(); + } + }, 120_000); + + it('retains a fresh export Retry-After instead of clearing it from stale sync config', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const local = await seedUnmappedExplicitContact(seeded.userId, { + displayName: 'Fresh Export Throttle', + }); + await databaseClient.query(` + UPDATE user_integrations + SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text)) + WHERE user_id = $1 AND provider = 'carddav' + `, [seeded.userId, '2000-01-01T00:00:00.000Z']); + fixture.queueSync('', { events: [], nextToken: 'fresh-export-throttle' }); + fixture.queueWrite('PUT', { + status: 429, + headers: { 'Retry-After': '120' }, + }); + const startedAt = Date.now(); + + const result = await carddavSync.syncUser(seeded.userId); + const finishedAt = Date.now(); + const [integration] = await integrationState(seeded.userId); + const retryAfterAt = integration.config.retryAfterAt; + + expect.soft(result).toMatchObject({ ok: false, retryAfterAt }); + expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 120_000); + expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 120_000); + expect.soft(fixture.requests + .filter(request => ['GET', 'PUT', 'DELETE'].includes(request.method)) + .map(request => request.method)).toEqual(['PUT']); + const projection = await projectionState(seeded.userId); + expect.soft(projection.contacts.find(contact => contact.id === local.id)).toMatchObject({ + vcard: local.card, + display_name: 'Fresh Export Throttle', + }); + expect.soft(projection.ledger).toEqual([]); + + fixture.reset(); + expect.soft(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: false, + retryAfterAt, + }); + expect.soft(fixture.requests).toEqual([]); + expect.soft((await integrationState(seeded.userId))[0].config.retryAfterAt) + .toBe(retryAfterAt); + await fixture.close(); + }, 120_000); + + it.each([ + ['missing update', undefined, 'PUT'], + ['malformed delete', 'tomorrow', 'DELETE'], + ])('keeps confirmed state after 429 with %s Retry-After', async (_label, value, method) => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const contact = await seedMappedExplicitContact(fixture, seeded.userId); + const before = await failureBoundaryState(seeded.userId); + fixture.reset(); + fixture.queueWrite(method, { + status: 429, + ...(value === undefined ? {} : { headers: { 'Retry-After': value } }), + }); + + const error = await (method === 'PUT' + ? carddavContactService.updateContact( + seeded.userId, + contact.id, + { displayName: 'Invalid Retry After Attempted' }, + ) + : carddavContactService.deleteContact(seeded.userId, contact.id) + ).catch(result => result); + + expect.soft(error).toMatchObject({ + name: 'CardDavError', + operation: method === 'PUT' ? 'update' : 'delete', + retryAfterAt: null, + status: 429, + }); + expect.soft(await failureBoundaryState(seeded.userId)).toEqual(before); + expect.soft(fixture.requests.map(request => request.method)).toEqual([method]); + await fixture.close(); + }, 120_000); + + it('records valid Retry-After for create without materializing or retrying the resource', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const before = await failureBoundaryState(seeded.userId); + fixture.queueWrite('PUT', { + status: 429, + headers: { 'Retry-After': '60' }, + }); + const draft = { + displayName: 'Throttled Create', + firstName: 'Throttled', + lastName: 'Create', + emails: [{ value: 'throttled-create@example.test', type: 'work', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [], + }; + const startedAt = Date.now(); + + const error = await carddavContactService.createContact(seeded.userId, draft) + .catch(result => result); + const finishedAt = Date.now(); + const after = await failureBoundaryState(seeded.userId); + const retryAfterAt = after.integrations[0].config.retryAfterAt; + + expect.soft(error).toMatchObject({ + name: 'CardDavError', + operation: 'create', + retryAfterAt, + status: 429, + }); + expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 60_000); + expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 60_000); + expect.soft(after.projection).toEqual(before.projection); + expect.soft(after.mappings).toEqual(before.mappings); + expect.soft(after.conflicts).toEqual(before.conflicts); + expect.soft(after.integrations).toEqual([{ + ...before.integrations[0], + config: { ...before.integrations[0].config, retryAfterAt }, + updated_at: expect.any(Date), + }]); + expect.soft(fixture.requests.map(request => request.method)) + .toEqual(['PROPFIND', 'PROPFIND', 'PROPFIND', 'PUT']); + + const immediate = await carddavContactService.createContact(seeded.userId, draft) + .catch(result => result); + expect.soft(immediate).toMatchObject({ + name: 'CardDavError', + operation: 'create', + retryAfterAt, + status: 429, + }); + expect.soft(fixture.requests.map(request => request.method)) + .toEqual(['PROPFIND', 'PROPFIND', 'PROPFIND', 'PUT']); + expect.soft(await failureBoundaryState(seeded.userId)).toEqual(after); + await fixture.close(); + }, 120_000); + + it('records discovery Retry-After before create and blocks immediate discovery', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const draft = { + displayName: 'Discovery Throttle', + emails: [{ value: 'discovery-throttle@example.test', type: 'work', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [], + }; + fixture.queueDiscovery({ + status: 429, + headers: { 'Retry-After': '120' }, + }); + const startedAt = Date.now(); + + const error = await carddavContactService.createContact(seeded.userId, draft) + .catch(result => result); + const finishedAt = Date.now(); + const retryAfterAt = error.retryAfterAt; + + expect.soft(error).toMatchObject({ + name: 'CardDavError', + operation: null, + retryAfterAt, + status: 429, + }); + expect.soft(Date.parse(retryAfterAt)).toBeGreaterThanOrEqual(startedAt + 120_000); + expect.soft(Date.parse(retryAfterAt)).toBeLessThanOrEqual(finishedAt + 120_000); + expect.soft((await integrationState(seeded.userId))[0].config.retryAfterAt) + .toBe(retryAfterAt); + expect.soft(fixture.requests.map(request => request.method)) + .toEqual(['PROPFIND', 'PROPFIND', 'PROPFIND']); + expect.soft((await projectionState(seeded.userId)).contacts).toEqual([]); + + fixture.reset(); + await expect(carddavContactService.createContact(seeded.userId, draft)).rejects.toMatchObject({ + name: 'CardDavError', + operation: 'create', + retryAfterAt, + status: 429, + }); + expect.soft(fixture.requests).toEqual([]); + await fixture.close(); + }, 120_000); + + it('records canonical-GET Retry-After while retaining initial and recovery-only intent', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const contact = await seedMappedExplicitContact(fixture, seeded.userId); + const attempted = { displayName: 'Canonical GET Throttle' }; + fixture.reset(); + fixture.queueWrite('GET', { + status: 429, + headers: { 'Retry-After': '120' }, + }); + + const firstError = await carddavContactService.updateContact( + seeded.userId, + contact.id, + attempted, + ).catch(result => result); + const afterFirst = await failureBoundaryState(seeded.userId); + const firstRetryAfterAt = afterFirst.integrations[0].config.retryAfterAt; + + expect.soft(firstError).toMatchObject({ name: 'CardDavAmbiguousWriteError' }); + expect.soft(firstError.cause).toMatchObject({ + name: 'CardDavError', retryAfterAt: firstRetryAfterAt, status: 429, + }); + expect.soft(afterFirst.mappings).toEqual([ + expect.objectContaining({ + mapping_status: 'pending_push', + pending_operation: 'update', + pending_started_at: expect.any(Date), + }), + ]); + expect.soft(fixture.requests.map(request => request.method)).toEqual(['PUT', 'GET']); + + await databaseClient.query(` + UPDATE user_integrations + SET config = jsonb_set(config, '{retryAfterAt}', to_jsonb($2::text)) + WHERE user_id = $1 AND provider = 'carddav' + `, [seeded.userId, '2000-01-01T00:00:00.000Z']); + const beforeRecovery = await failureBoundaryState(seeded.userId); + fixture.reset(); + fixture.queueWrite('GET', { + status: 429, + headers: { 'Retry-After': '240' }, + }); + + const recoveryError = await carddavContactService.updateContact( + seeded.userId, + contact.id, + attempted, + ).catch(result => result); + const afterRecovery = await failureBoundaryState(seeded.userId); + const retryAfterAt = afterRecovery.integrations[0].config.retryAfterAt; + + expect.soft(recoveryError).toMatchObject({ name: 'CardDavAmbiguousWriteError' }); + expect.soft(recoveryError.cause).toMatchObject({ + name: 'CardDavError', retryAfterAt, status: 429, + }); + expect.soft(fixture.requests.map(request => request.method)).toEqual(['GET']); + expect.soft(afterRecovery.projection).toEqual(beforeRecovery.projection); + expect.soft(afterRecovery.mappings).toEqual(beforeRecovery.mappings); + expect.soft(afterRecovery.conflicts).toEqual(beforeRecovery.conflicts); + expect.soft(Date.parse(retryAfterAt)).toBeGreaterThan(Date.parse(firstRetryAfterAt)); + + fixture.reset(); + await expect(carddavContactService.updateContact( + seeded.userId, + contact.id, + attempted, + )).rejects.toMatchObject({ status: 429, retryAfterAt }); + expect.soft(fixture.requests).toEqual([]); + expect.soft((await failureBoundaryState(seeded.userId)).mappings) + .toEqual(afterRecovery.mappings); + await fixture.close(); + }, 120_000); + + it('makes no export request when the connection is replaced between apply and export', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const replacementGeneration = randomUUID(); + const { rows: [localBook] } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Generation Export Local') RETURNING id + `, [seeded.userId]); + const contact = { + uid: 'generation-export-local', + displayName: 'Generation Export Local', + emails: [{ value: 'generation-export@example.test', type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + }; + const card = generateVCard(contact); + const { rows: [localContact] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, + primary_email, emails, phones, is_auto + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, false) + RETURNING id + `, [ + localBook.id, + seeded.userId, + contact.uid, + card, + createHash('md5').update(card).digest('hex'), + contact.displayName, + contact.emails[0].value, + JSON.stringify(contact.emails), + ]); + fixture.queueSync('', { events: [], nextToken: 'generation-export-token' }); + await databaseClient.query(` + CREATE FUNCTION replace_generation_before_export() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + UPDATE user_integrations + SET config = jsonb_set( + config, '{connectionGeneration}', to_jsonb('${replacementGeneration}'::text) + ) + WHERE user_id = NEW.user_id AND provider = 'carddav'; + RETURN NEW; + END + $$ + `); + await databaseClient.query(` + CREATE TRIGGER replace_generation_before_export + AFTER UPDATE OF remote_sync_revision ON address_books + FOR EACH ROW WHEN (NEW.source = 'carddav') + EXECUTE FUNCTION replace_generation_before_export() + `); + + try { + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: false, + error: 'CardDAV sync plan is stale', + }); + expect(fixture.requests.filter(request => ( + request.method === 'GET' || request.method === 'PUT' || request.method === 'DELETE' + ))).toEqual([]); + const state = await projectionState(seeded.userId); + expect(state.ledger).toEqual([]); + expect(state.contacts.find(row => row.id === localContact.id)).toMatchObject({ + vcard: card, + display_name: contact.displayName, + }); + expect((await integrationState(seeded.userId))[0].config.connectionGeneration) + .toBe(replacementGeneration); + } finally { + await databaseClient.query('DROP TRIGGER replace_generation_before_export ON address_books'); + await databaseClient.query('DROP FUNCTION replace_generation_before_export()'); + await fixture.close(); + } + }, 120_000); + + it('syncUser recovers once from valid-sync-token and commits one full reconciliation', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const unrelatedBook = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Unrelated') RETURNING id, sync_token + `, [seeded.userId]); + const retainedHref = fixture.href('retained.vcf'); + const changedHref = fixture.href('changed.vcf'); + const removedHref = fixture.href('removed.vcf'); + const retainedVcard = remoteVcard('retained', 'Retained Contact'); + const changedBeforeVcard = remoteVcard('changed', 'Changed Before'); + const removedVcard = remoteVcard('removed', 'Removed Contact'); + fixture.putContact(retainedHref, '"retained-1"', retainedVcard); + fixture.putContact(changedHref, '"changed-1"', changedBeforeVcard); + fixture.putContact(removedHref, '"removed-1"', removedVcard); + fixture.queueSync('', { + events: [ + { href: retainedHref, etag: '"retained-1"' }, + { href: changedHref, etag: '"changed-1"' }, + { href: removedHref, etag: '"removed-1"' }, + ], + nextToken: 'seed-token', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, bookCount: 1, contactCount: 3, + }); + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = 'stored-invalid-token', remote_sync_revision = 7 + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + const before = await projectionState(seeded.userId); + const remoteBook = before.books.find(row => row.source === 'carddav'); + const retainedContact = before.contacts.find(row => row.primary_email === 'retained@example.test'); + const changedContact = before.contacts.find(row => row.primary_email === 'changed@example.test'); + const changedVcard = remoteVcard('changed', 'Changed After'); + const addedVcard = remoteVcard('added', 'Added Contact'); + const addedHref = fixture.href('added.vcf'); + fixture.putContact(changedHref, '"changed-2"', changedVcard); + fixture.putContact(addedHref, '"added-1"', addedVcard); + fixture.deleteContact(removedHref); + fixture.reset(); + fixture.queueSync('stored-invalid-token', { + status: 403, + rawBody: '', + }); + fixture.queueSync('', { + events: [ + { href: retainedHref, etag: '"retained-1"' }, + { href: changedHref, etag: '"changed-2"' }, + { href: addedHref, etag: '"added-1"' }, + ], + nextToken: 'recovered-token', + }); + + const result = await carddavSync.syncUser(seeded.userId); + + expect(result).toEqual({ + ok: true, + bookCount: 1, + contactCount: 3, + remote: 3, + fetched: 3, + updated: 2, + removed: 1, + fallback: 1, + exportFailures: [], + }); + const after = await projectionState(seeded.userId); + const afterBook = after.books.find(row => row.id === remoteBook.id); + expect(afterBook).toEqual({ + ...remoteBook, + remote_sync_token: 'recovered-token', + remote_sync_capability: 'sync-collection', + remote_sync_revision: '8', + sync_token: expect.any(String), + remote_projection_fingerprint: createHash('sha256').update(JSON.stringify([[ + unrelatedBook.rows[0].id, + unrelatedBook.rows[0].sync_token, + ]])).digest('hex'), + }); + expect(afterBook.sync_token).not.toBe(remoteBook.sync_token); + expect(after.books.find(row => row.id === unrelatedBook.rows[0].id)).toEqual({ + id: unrelatedBook.rows[0].id, + source: 'local', + external_url: null, + sync_token: unrelatedBook.rows[0].sync_token, + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: '0', + remote_projection_fingerprint: null, + }); + const addedContact = after.contacts.find(row => row.primary_email === 'added@example.test'); + expect(after.contacts).toEqual([ + expectedLocalContact(retainedHref, retainedVcard, remoteBook.id, seeded.userId, retainedContact.id), + expectedLocalContact(changedHref, changedVcard, remoteBook.id, seeded.userId, changedContact.id), + expectedLocalContact(addedHref, addedVcard, remoteBook.id, seeded.userId, addedContact.id), + ].sort((a, b) => a.uid.localeCompare(b.uid))); + expect(after.ledger).toEqual([ + [addedHref, '"added-1"', addedVcard, 'added@example.test', addedContact.id], + [changedHref, '"changed-2"', changedVcard, 'changed@example.test', changedContact.id], + [retainedHref, '"retained-1"', retainedVcard, 'retained@example.test', retainedContact.id], + ].map(([href, etag, card, email, contactId]) => ({ + address_book_id: remoteBook.id, + href, + remote_etag: etag, + vcard: card, + primary_email: email, + local_contact_id: contactId, + }))); + const [integration] = await integrationState(seeded.userId); + expect(integration.provider).toBe('carddav'); + expect(integration.config).toEqual({ + ...seeded.config, + lastError: null, + lastSyncAt: expect.any(String), + bookCount: 1, + contactCount: 3, + exportFailures: [], + }); + expect(integration.config.password).toBe(seeded.encryptedPassword); + expect(integration.config.password).toMatch(/^enc:v1:/); + expect(integration.config.password).not.toBe(seeded.password); + expect(Number.isNaN(Date.parse(integration.config.lastSyncAt))).toBe(false); + expect(fixture.requests.map(request => ({ + method: request.method, + path: request.path, + depth: request.depth, + token: request.body.match(/([\s\S]*?)<\/sync-token>/)?.[1], + }))).toEqual([ + { method: 'PROPFIND', path: '/', depth: '0', token: undefined }, + { method: 'PROPFIND', path: '/principals/fixture-user/', depth: '0', token: undefined }, + { method: 'PROPFIND', path: '/addressbooks/fixture-user/', depth: '1', token: undefined }, + { method: 'REPORT', path: '/addressbooks/fixture-user/contacts/', depth: '0', token: 'stored-invalid-token' }, + { method: 'REPORT', path: '/addressbooks/fixture-user/contacts/', depth: '0', token: '' }, + { method: 'REPORT', path: '/addressbooks/fixture-user/contacts/', depth: '0', token: undefined }, + ]); + expect(fixture.requests.every(request => ( + request.authorization === 'Basic Zml4dHVyZS11c2VyOmZpeHR1cmUtcGFzc3dvcmQ=' + ))).toBe(true); + expect(fixture.counters).toMatchObject({ + requests: 6, + propfind: 3, + sync: 2, + multiget: 1, + syncTokens: ['stored-invalid-token', ''], + multigetSizes: [3], + }); + await fixture.close(); + }, 120_000); + + it('valid-sync-token recovery is bounded when the empty-token recovery fails again', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const contact = await seedSingleRemoteContact(fixture, seeded.userId); + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = 'invalid-twice', remote_sync_revision = 4 + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + const before = await projectionState(seeded.userId, { timestamps: true }); + fixture.reset(); + fixture.queueSync('invalid-twice', { status: 403, precondition: 'valid-sync-token' }); + fixture.queueSync('', { status: 403, precondition: 'valid-sync-token' }); + + const failed = await carddavSync.syncUser(seeded.userId); + + expect(failed).toEqual({ + ok: false, + error: 'CardDAV request failed (403 Forbidden)', + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, + }); + expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before); + const [failedIntegration] = await integrationState(seeded.userId); + expect(failedIntegration.config).toEqual({ + ...seeded.config, + lastError: 'CardDAV request failed (403 Forbidden)', + lastSyncAt: expect.any(String), + bookCount: 1, + contactCount: 1, + exportFailures: [], + }); + expect(fixture.counters).toMatchObject({ + requests: 5, + propfind: 3, + sync: 2, + multiget: 0, + syncTokens: ['invalid-twice', ''], + }); + + fixture.reset(); + fixture.queueSync('invalid-twice', { + events: [{ href: contact.href, etag: contact.etag }], + nextToken: 'guard-released-token', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + expect(fixture.counters.syncTokens).toEqual(['invalid-twice']); + await fixture.close(); + }, 120_000); + + it('malformed empty-token recovery page leaves projection exact and releases the guard', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const contact = await seedSingleRemoteContact(fixture, seeded.userId); + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = 'invalid-malformed', remote_sync_revision = 9 + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + const before = await projectionState(seeded.userId, { timestamps: true }); + fixture.reset(); + fixture.queueSync('invalid-malformed', { status: 403, precondition: 'valid-sync-token' }); + fixture.queueSync('', { + status: 207, + rawBody: '', + }); + + const failed = await carddavSync.syncUser(seeded.userId); + + expect(failed.ok).toBe(false); + expect(failed.error).toMatch(/multistatus/i); + expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before); + const [failedIntegration] = await integrationState(seeded.userId); + expect(failedIntegration.config).toEqual({ + ...seeded.config, + lastError: failed.error, + lastSyncAt: expect.any(String), + bookCount: 1, + contactCount: 1, + exportFailures: [], + }); + expect(fixture.counters).toMatchObject({ + requests: 5, + propfind: 3, + sync: 2, + multiget: 0, + syncTokens: ['invalid-malformed', ''], + }); + + fixture.reset(); + fixture.queueSync('invalid-malformed', { + events: [{ href: contact.href, etag: contact.etag }], + nextToken: 'malformed-guard-released', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + await fixture.close(); + }, 120_000); + + it('late multiget recovery failure rolls back the complete production plan', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + await seedSingleRemoteContact(fixture, seeded.userId); + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = 'invalid-late-batch', remote_sync_revision = 12 + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + const before = await projectionState(seeded.userId, { timestamps: true }); + const events = []; + for (let index = 0; index < 101; index++) { + const uid = `batch-${String(index).padStart(3, '0')}`; + const href = fixture.href(`${uid}.vcf`); + const card = remoteVcard(uid, `Batch ${index}`); + const etag = `"${uid}-1"`; + fixture.putContact(href, etag, card); + events.push({ href, etag }); + } + fixture.reset(); + fixture.queueSync('invalid-late-batch', { status: 403, precondition: 'valid-sync-token' }); + fixture.queueSync('', { events, nextToken: 'must-not-commit' }); + fixture.queueMultiget({}); + fixture.queueMultiget({ status: 500, rawBody: 'late batch failed' }); + + const failed = await carddavSync.syncUser(seeded.userId); + + expect(failed).toEqual({ + ok: false, + error: 'CardDAV request failed (500 Internal Server Error)', + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, + }); + expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before); + const [failedIntegration] = await integrationState(seeded.userId); + expect(failedIntegration.config).toEqual({ + ...seeded.config, + lastError: failed.error, + lastSyncAt: expect.any(String), + bookCount: 1, + contactCount: 1, + exportFailures: [], + }); + expect(fixture.counters).toMatchObject({ + requests: 7, + propfind: 3, + sync: 2, + multiget: 2, + syncTokens: ['invalid-late-batch', ''], + multigetSizes: [100, 1], + }); + fixture.reset(); + fixture.queueSync('invalid-late-batch', { status: 403, precondition: 'valid-sync-token' }); + fixture.queueSync('', { + events: [{ href: before.ledger[0].href, etag: before.ledger[0].remote_etag }], + nextToken: 'late-batch-guard-released', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + await fixture.close(); + }, 120_000); + + it('rollback from a database trigger preserves projection and writes only guarded failure status', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const contact = await seedSingleRemoteContact(fixture, seeded.userId); + const before = await projectionState(seeded.userId, { timestamps: true }); + const changedCard = remoteVcard('seeded', 'Trigger Changed'); + fixture.putContact(contact.href, '"seeded-2"', changedCard); + fixture.reset(); + fixture.queueSync(contact.token, { + events: [{ href: contact.href, etag: '"seeded-2"' }], + nextToken: 'trigger-must-not-commit', + }); + await databaseClient.query(` + CREATE FUNCTION task24_force_book_rollback() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + RAISE EXCEPTION 'forced sync rollback'; + END + $$ + `); + await databaseClient.query(` + CREATE TRIGGER task24_force_book_rollback + BEFORE UPDATE ON address_books + FOR EACH ROW EXECUTE FUNCTION task24_force_book_rollback() + `); + + const failed = await carddavSync.syncUser(seeded.userId); + await databaseClient.query('DROP TRIGGER task24_force_book_rollback ON address_books'); + await databaseClient.query('DROP FUNCTION task24_force_book_rollback()'); + + expect(failed).toEqual({ + ok: false, + error: 'forced sync rollback', + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, + }); + expect(await projectionState(seeded.userId, { timestamps: true })).toEqual(before); + const [failedIntegration] = await integrationState(seeded.userId); + expect(failedIntegration.config).toEqual({ + ...seeded.config, + lastError: 'forced sync rollback', + lastSyncAt: expect.any(String), + bookCount: 1, + contactCount: 1, + exportFailures: [], + }); + expect(fixture.counters).toMatchObject({ + requests: 5, + propfind: 3, + sync: 1, + multiget: 1, + syncTokens: [contact.token], + multigetSizes: [1], + }); + + fixture.reset(); + fixture.queueSync(contact.token, { + events: [{ href: contact.href, etag: '"seeded-2"' }], + nextToken: 'trigger-released-token', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + await fixture.close(); + }, 120_000); + + it('stale recovery CAS retries once and never regresses concurrent token or revision state', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const contact = await seedSingleRemoteContact(fixture, seeded.userId); + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = 'stored-invalid-cas', remote_sync_revision = 7 + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + const before = await projectionState(seeded.userId); + const changedCard = remoteVcard('seeded', 'Stale Changed'); + fixture.putContact(contact.href, '"stale-2"', changedCard); + const firstBarrier = deferred(); + const firstReached = deferred(); + const secondBarrier = deferred(); + const secondReached = deferred(); + fixture.reset(); + fixture.queueSync('stored-invalid-cas', { + status: 403, + precondition: 'valid-sync-token', + }); + fixture.queueSync('', { + events: [{ href: contact.href, etag: '"stale-2"' }], + nextToken: 'stale-plan-one', + waitFor: firstBarrier.promise, + reached: firstReached.resolve, + }); + fixture.queueSync('concurrent-token-one', { + events: [{ href: contact.href, etag: '"stale-2"' }], + nextToken: 'stale-plan-two', + waitFor: secondBarrier.promise, + reached: secondReached.resolve, + }); + + const pending = carddavSync.syncUser(seeded.userId); + await firstReached.promise; + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = 'concurrent-token-one', remote_sync_revision = 8 + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + firstBarrier.resolve(); + await secondReached.promise; + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = 'concurrent-token-two', remote_sync_revision = 9 + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + secondBarrier.resolve(); + + const failed = await pending; + + expect(failed).toEqual({ + ok: false, + error: 'CardDAV sync plan is stale', + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, + }); + const after = await projectionState(seeded.userId); + expect(after).toEqual({ + ...before, + books: before.books.map(book => book.source === 'carddav' ? { + ...book, + remote_sync_token: 'concurrent-token-two', + remote_sync_revision: '9', + } : book), + }); + expect(after.contacts).toHaveLength(1); + expect(after.contacts[0].display_name).toBe('Seeded Contact'); + expect(after.ledger).toHaveLength(1); + expect(after.ledger[0].remote_etag).toBe(contact.etag); + const [failedIntegration] = await integrationState(seeded.userId); + expect(failedIntegration.config).toEqual({ + ...seeded.config, + lastError: 'CardDAV sync plan is stale', + lastSyncAt: expect.any(String), + bookCount: 1, + contactCount: 1, + exportFailures: [], + }); + expect(fixture.counters).toMatchObject({ + requests: 8, + propfind: 3, + sync: 3, + multiget: 2, + syncTokens: ['stored-invalid-cas', '', 'concurrent-token-one'], + multigetSizes: [1, 1], + }); + fixture.reset(); + fixture.queueSync('concurrent-token-two', { + events: [], + nextToken: 'stale-guard-released', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + await fixture.close(); + }, 120_000); + + it('initial no-change changed removed and snapshot filter paths advance one revision each', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const href = fixture.href('lifecycle.vcf'); + const initialCard = remoteVcard('lifecycle', 'Lifecycle Initial'); + fixture.putContact(href, '"lifecycle-1"', initialCard); + fixture.queueSync('', { + events: [{ href, etag: '"lifecycle-1"' }], + nextToken: 'lifecycle-1', + }); + + expect(await carddavSync.syncUser(seeded.userId)).toEqual({ + ok: true, bookCount: 1, contactCount: 1, + remote: 1, fetched: 1, updated: 1, removed: 0, + fallback: 0, exportFailures: [], + }); + const initial = await projectionState(seeded.userId); + const initialBook = initial.books.find(row => row.source === 'carddav'); + expect(initialBook.remote_sync_revision).toBe('1'); + + fixture.reset(); + fixture.queueSync('lifecycle-1', { events: [], nextToken: 'lifecycle-2' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, fetched: 0, updated: 0, removed: 0, + }); + const unchanged = await projectionState(seeded.userId); + expect(unchanged.books.find(row => row.id === initialBook.id)).toMatchObject({ + remote_sync_token: 'lifecycle-2', + remote_sync_revision: '2', + sync_token: initialBook.sync_token, + }); + expect(unchanged.contacts).toEqual(initial.contacts); + expect(unchanged.ledger).toEqual(initial.ledger); + expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(1); + expect(fixture.counters.multiget).toBe(0); + + const changedCard = remoteVcard('lifecycle', 'Lifecycle Changed'); + fixture.putContact(href, '"lifecycle-2"', changedCard); + fixture.reset(); + fixture.queueSync('lifecycle-2', { + events: [{ href, etag: '"lifecycle-2"' }], + nextToken: 'lifecycle-3', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, fetched: 1, updated: 1, removed: 0, + }); + const changed = await projectionState(seeded.userId); + const changedBook = changed.books.find(row => row.id === initialBook.id); + expect(changedBook).toMatchObject({ + remote_sync_token: 'lifecycle-3', + remote_sync_revision: '3', + }); + expect(changedBook.sync_token).not.toBe(initialBook.sync_token); + expect(changed.contacts[0].display_name).toBe('Lifecycle Changed'); + expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(1); + + fixture.deleteContact(href); + fixture.reset(); + fixture.queueSync('lifecycle-3', { + events: [{ href, status: 404 }], + nextToken: 'lifecycle-4', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, fetched: 0, updated: 0, removed: 1, + }); + const removed = await projectionState(seeded.userId); + const removedBook = removed.books.find(row => row.id === initialBook.id); + expect(removedBook).toMatchObject({ + remote_sync_token: 'lifecycle-4', + remote_sync_revision: '4', + }); + expect(removedBook.sync_token).not.toBe(changedBook.sync_token); + expect(removed.contacts).toEqual([]); + expect(removed.ledger).toEqual([]); + expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(0); + + fixture.reset(); + fixture.queueSync('lifecycle-4', { status: 405 }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, fetched: 0, updated: 0, removed: 0, fallback: 1, + }); + const snapshot = await projectionState(seeded.userId); + expect(snapshot.books.find(row => row.id === initialBook.id)).toEqual({ + ...removedBook, + remote_sync_token: null, + remote_sync_capability: 'snapshot', + remote_sync_revision: '5', + }); + expect(snapshot.contacts).toEqual([]); + expect(snapshot.ledger).toEqual([]); + expect(fixture.counters).toMatchObject({ + propfind: 3, + sync: 1, + multiget: 0, + addressbookQuery: 1, + snapshotFilters: [1], + syncTokens: ['lifecycle-4'], + }); + await fixture.close(); + }, 120_000); + + it('reconciles the mapped-contact pull matrix without disturbing durable conflicts', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const cases = ['additional', 'pending-etag', 'conflict-edit', 'conflict-delete', 'sibling']; + const hrefs = Object.fromEntries(cases.map(name => [name, fixture.href(`${name}.vcf`)])); + const cards = Object.fromEntries(cases.map(name => [ + name, + remoteVcard(name, `${name} Initial`), + ])); + for (const name of cases) fixture.putContact(hrefs[name], `"${name}-1"`, cards[name]); + fixture.queueSync('', { + events: cases.map(name => ({ href: hrefs[name], etag: `"${name}-1"` })), + nextToken: 'mapping-matrix-1', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, contactCount: 5, + }); + + const { rows: initialMappings } = await databaseClient.query(` + SELECT o.href, o.address_book_id, o.local_contact_id, + o.remote_semantic_hash, o.local_contact_hash + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + ORDER BY o.href + `, [seeded.userId]); + const initialByHref = new Map(initialMappings.map(mapping => [mapping.href, mapping])); + const localNames = { + 'pending-etag': 'Pending Local Edit', + 'conflict-edit': 'Conflict Local Edit', + 'conflict-delete': 'Delete Conflict Local Edit', + }; + for (const [name, displayName] of Object.entries(localNames)) { + await databaseClient.query( + `UPDATE contacts SET display_name = $1, updated_at = NOW() WHERE id = $2`, + [displayName, initialByHref.get(hrefs[name]).local_contact_id], + ); + } + await databaseClient.query(` + UPDATE carddav_remote_objects + SET mapping_status = CASE + WHEN href = $1 THEN 'pending_push' + WHEN href = ANY($2::text[]) THEN 'conflict' + ELSE mapping_status + END, + mapping_revision = 10 + WHERE address_book_id = $3 + `, [ + hrefs['pending-etag'], + [hrefs['conflict-edit'], hrefs['conflict-delete']], + initialMappings[0].address_book_id, + ]); + const conflictIds = {}; + const conflictLocalSnapshots = {}; + for (const name of ['conflict-edit', 'conflict-delete']) { + const mapping = initialByHref.get(hrefs[name]); + const localVCard = remoteVcard(name, localNames[name]); + const localTombstone = name === 'conflict-delete'; + const { rows: [conflict] } = await databaseClient.query(` + INSERT INTO carddav_conflicts ( + address_book_id, href, user_id, base_local_hash, remote_etag, + local_vcard, remote_vcard, local_tombstone, remote_tombstone + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false) + RETURNING id + `, [ + mapping.address_book_id, + mapping.href, + seeded.userId, + mapping.local_contact_hash, + `"${name}-1"`, + localVCard, + cards[name], + localTombstone, + ]); + conflictIds[name] = conflict.id; + conflictLocalSnapshots[name] = { localVCard, localTombstone }; + } + + const additionalCard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:additional', + 'FN:additional Initial', + 'EMAIL:additional@example.test', + 'item1.URL:https://example.test/profile', + 'item1.X-ABLabel:Portfolio', + 'END:VCARD', + ].join('\n'); + const conflictEditCard = remoteVcard('conflict-edit', 'Conflict Remote Edit'); + const siblingCard = remoteVcard('sibling', 'Sibling Remote Edit'); + fixture.putContact(hrefs.additional, '"additional-2"', additionalCard); + fixture.putContact(hrefs['pending-etag'], '"pending-etag-2"', cards['pending-etag']); + fixture.putContact(hrefs['conflict-edit'], '"conflict-edit-2"', conflictEditCard); + fixture.deleteContact(hrefs['conflict-delete']); + fixture.putContact(hrefs.sibling, '"sibling-2"', siblingCard); + fixture.reset(); + fixture.queueSync('mapping-matrix-1', { + events: [ + { href: hrefs.additional, etag: '"additional-2"' }, + { href: hrefs['pending-etag'], etag: '"pending-etag-2"' }, + { href: hrefs['conflict-edit'], etag: '"conflict-edit-2"' }, + { href: hrefs['conflict-delete'], status: 404 }, + { href: hrefs.sibling, etag: '"sibling-2"' }, + ], + nextToken: 'mapping-matrix-2', + }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, contactCount: 5, + }); + const { rows: state } = await databaseClient.query(` + SELECT o.href, o.remote_etag, o.vcard, o.mapping_status, + o.remote_semantic_hash, o.local_contact_hash, + o.pending_operation, o.pending_vcard, o.pending_local_hash, + o.pending_remote_semantic_hash, o.pending_started_at, + o.mapping_revision::text, c.uid, c.display_name, c.first_name, + c.last_name, c.emails, c.phones, c.organization, c.notes, + c.photo_data, c.additional_fields, + conflict.id AS conflict_id, conflict.remote_etag AS conflict_remote_etag, + conflict.remote_vcard AS conflict_remote_vcard, + conflict.local_vcard AS conflict_local_vcard, + conflict.local_tombstone, + conflict.remote_tombstone + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + LEFT JOIN carddav_conflicts conflict + ON conflict.address_book_id = o.address_book_id + AND conflict.href = o.href AND conflict.status = 'unresolved' + WHERE c.user_id = $1 + ORDER BY o.href + `, [seeded.userId]); + const byHref = new Map(state.map(mapping => [mapping.href, mapping])); + const { rows: [bookState] } = await databaseClient.query(` + SELECT remote_sync_token FROM address_books + WHERE user_id = $1 AND source = 'carddav' + `, [seeded.userId]); + + expect(bookState.remote_sync_token).toBe('mapping-matrix-2'); + expect(byHref.get(hrefs.additional)).toMatchObject({ + remote_etag: '"additional-2"', + vcard: additionalCard, + mapping_status: 'synced', + remote_semantic_hash: semanticVCardHash(parseVCardDocument(additionalCard)), + mapping_revision: '11', + display_name: 'additional Initial', + additional_fields: [expect.objectContaining({ + kind: 'url', label: 'Portfolio', value: 'https://example.test/profile', + })], + conflict_id: null, + }); + const storedContactHash = contact => localContactHash({ + uid: contact.uid, + displayName: contact.display_name, + firstName: contact.first_name, + lastName: contact.last_name, + emails: contact.emails, + phones: contact.phones, + organization: contact.organization, + notes: contact.notes, + photoData: contact.photo_data, + additionalFields: contact.additional_fields, + }); + expect(byHref.get(hrefs.additional).local_contact_hash).toBe( + storedContactHash(byHref.get(hrefs.additional)), + ); + expect(byHref.get(hrefs['pending-etag'])).toMatchObject({ + remote_etag: '"pending-etag-2"', + vcard: cards['pending-etag'], + mapping_status: 'pending_push', + remote_semantic_hash: initialByHref.get(hrefs['pending-etag']).remote_semantic_hash, + local_contact_hash: initialByHref.get(hrefs['pending-etag']).local_contact_hash, + mapping_revision: '11', + display_name: localNames['pending-etag'], + pending_operation: null, + pending_vcard: null, + pending_local_hash: null, + pending_remote_semantic_hash: null, + pending_started_at: null, + conflict_id: null, + }); + expect(byHref.get(hrefs['conflict-edit'])).toMatchObject({ + remote_etag: '"conflict-edit-1"', + vcard: cards['conflict-edit'], + mapping_status: 'conflict', + remote_semantic_hash: initialByHref.get(hrefs['conflict-edit']).remote_semantic_hash, + local_contact_hash: initialByHref.get(hrefs['conflict-edit']).local_contact_hash, + mapping_revision: '11', + display_name: localNames['conflict-edit'], + conflict_id: conflictIds['conflict-edit'], + conflict_local_vcard: conflictLocalSnapshots['conflict-edit'].localVCard, + local_tombstone: conflictLocalSnapshots['conflict-edit'].localTombstone, + conflict_remote_etag: '"conflict-edit-2"', + conflict_remote_vcard: conflictEditCard, + remote_tombstone: false, + }); + expect(byHref.get(hrefs['conflict-delete'])).toMatchObject({ + remote_etag: '"conflict-delete-1"', + vcard: cards['conflict-delete'], + mapping_status: 'conflict', + remote_semantic_hash: initialByHref.get(hrefs['conflict-delete']).remote_semantic_hash, + local_contact_hash: initialByHref.get(hrefs['conflict-delete']).local_contact_hash, + mapping_revision: '11', + display_name: localNames['conflict-delete'], + conflict_id: conflictIds['conflict-delete'], + conflict_local_vcard: conflictLocalSnapshots['conflict-delete'].localVCard, + local_tombstone: conflictLocalSnapshots['conflict-delete'].localTombstone, + conflict_remote_etag: null, + conflict_remote_vcard: null, + remote_tombstone: true, + }); + expect(byHref.get(hrefs.sibling)).toMatchObject({ + remote_etag: '"sibling-2"', + vcard: siblingCard, + mapping_status: 'synced', + remote_semantic_hash: semanticVCardHash(parseVCardDocument(siblingCard)), + mapping_revision: '11', + display_name: 'Sibling Remote Edit', + conflict_id: null, + }); + expect(byHref.get(hrefs.sibling).local_contact_hash).toBe( + storedContactHash(byHref.get(hrefs.sibling)), + ); + await fixture.close(); + }, 120_000); + + async function createPushOriginContact(fixture, userId, draft) { + const created = await carddavContactService.createContact(userId, { + firstName: null, + lastName: null, + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [], + ...draft, + }); + const { rows: [row] } = await databaseClient.query(` + SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, + o.remote_semantic_hash, o.local_contact_hash, o.mapping_status, + o.mapping_revision::text, + c.id AS contact_id, c.address_book_id AS contact_book_id, c.uid, + c.display_name, c.first_name, c.last_name, c.organization, c.notes, + c.emails, c.phones, c.photo_data, c.additional_fields, + cb.source AS contact_book_source + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + JOIN address_books cb ON cb.id = c.address_book_id + WHERE c.user_id = $1 + `, [userId]); + // A push-origin mapping links a contact that lives OUTSIDE the CardDAV book. + expect(row.contact_book_source).not.toBe('carddav'); + expect(row.contact_book_id).not.toBe(row.address_book_id); + expect(row.contact_id).toBe(created.id); + return { created, mapping: row }; + } + + async function pushOriginState(userId) { + const { rows: [row] } = await databaseClient.query(` + SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, + o.remote_semantic_hash, o.local_contact_hash, o.mapping_status, + o.mapping_revision::text, + c.id AS contact_id, c.address_book_id AS contact_book_id, c.uid, + c.display_name, c.first_name, c.last_name, c.organization, c.notes, + c.emails, c.phones, c.photo_data, c.additional_fields + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + WHERE c.user_id = $1 + `, [userId]); + return row; + } + + function contactRowHash(row) { + return localContactHash({ + uid: row.uid, + displayName: row.display_name, + firstName: row.first_name, + lastName: row.last_name, + emails: row.emails, + phones: row.phones, + organization: row.organization, + notes: row.notes, + photoData: row.photo_data, + additionalFields: row.additional_fields, + }); + } + + it('applies a remote edit to a push-origin contact and stays converged', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const { created, mapping: before } = await createPushOriginContact(fixture, seeded.userId, { + displayName: 'Push Origin', + firstName: 'Push', + lastName: 'Origin', + emails: [{ value: 'push-origin@example.test', type: 'work', primary: true }], + organization: 'Origin Co', + }); + + // A remote-only edit of the resource MailFlow created: same UID, new content. + const editedVcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${created.uid}`, + 'FN:Push Origin', + 'EMAIL:push-origin@example.test', + 'ORG:Edited Remotely', + 'NOTE:remote-only-edit', + 'END:VCARD', + ].join('\n'); + fixture.putContact(before.href, '"push-origin-edited"', editedVcard); + fixture.queueSync('', { + events: [{ rawHref: before.href, etag: '"push-origin-edited"' }], + nextToken: 'push-origin-edited-token', + }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const after = await pushOriginState(seeded.userId); + // The local contact adopted the edit in place: same IDs, same local book. + expect(after.contact_id).toBe(before.contact_id); + expect(after.contact_book_id).toBe(before.contact_book_id); + expect(after.uid).toBe(before.uid); + expect(after.organization).toBe('Edited Remotely'); + expect(after.notes).toBe('remote-only-edit'); + // The mapping advanced its ETag, retained the lossless remote vCard, stays synced. + expect(after.remote_etag).toBe('"push-origin-edited"'); + expect(after.vcard).toBe(editedVcard); + expect(after.mapping_status).toBe('synced'); + expect(after.remote_semantic_hash) + .toBe(semanticVCardHash(parseVCardDocument(editedVcard))); + // Hashes converge: the mapping's local hash matches the stored contact. + expect(after.local_contact_hash).toBe(contactRowHash(after)); + // No spurious conflict. + const { rows: conflicts } = await databaseClient.query( + "SELECT 1 FROM carddav_conflicts WHERE user_id = $1 AND status = 'unresolved'", + [seeded.userId], + ); + expect(conflicts).toEqual([]); + + // A byte-identical incremental no-change sync leaves the mapping + contact untouched. + fixture.queueSync('push-origin-edited-token', { + events: [], + nextToken: 'push-origin-noop-token', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + const settled = await pushOriginState(seeded.userId); + expect(settled).toMatchObject({ + remote_etag: after.remote_etag, + vcard: after.vcard, + remote_semantic_hash: after.remote_semantic_hash, + local_contact_hash: after.local_contact_hash, + mapping_status: 'synced', + mapping_revision: after.mapping_revision, + organization: 'Edited Remotely', + notes: 'remote-only-edit', + }); + } finally { + await fixture.close(); + } + }, 120_000); + + it('creates a conflict when a push-origin contact is edited on both sides', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const { created, mapping: before } = await createPushOriginContact(fixture, seeded.userId, { + displayName: 'Push Both', + emails: [{ value: 'push-both@example.test', type: 'work', primary: true }], + organization: 'Origin Co', + }); + + // Concurrent local edit (no push yet) plus a remote-only edit of the same resource. + await databaseClient.query( + 'UPDATE contacts SET organization = $1, updated_at = NOW() WHERE id = $2', + ['Local Edit', created.id], + ); + const remoteVcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${created.uid}`, + 'FN:Push Both', + 'EMAIL:push-both@example.test', + 'ORG:Remote Edit', + 'END:VCARD', + ].join('\n'); + fixture.putContact(before.href, '"push-both-remote"', remoteVcard); + fixture.queueSync('', { + events: [{ rawHref: before.href, etag: '"push-both-remote"' }], + nextToken: 'push-both-token', + }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + // The simultaneous edit surfaces as a normal conflict; the local edit is retained. + const { rows: [conflict] } = await databaseClient.query( + `SELECT status, remote_tombstone, local_tombstone + FROM carddav_conflicts WHERE user_id = $1 AND href = $2`, + [seeded.userId, before.href], + ); + expect(conflict).toMatchObject({ + status: 'unresolved', + remote_tombstone: false, + local_tombstone: false, + }); + const state = await pushOriginState(seeded.userId); + expect(state.mapping_status).toBe('conflict'); + expect(state.organization).toBe('Local Edit'); + } finally { + await fixture.close(); + } + }, 120_000); + + it('snapshots a lossless local vCard when sync creates a conflict, so keep-mailflow preserves unmodeled properties', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + + // Import a remote contact whose retained vCard carries unmodeled server + // properties the local projection drops. + const uid = 'conflict-lossless'; + const href = fixture.href(`${uid}.vcf`); + const importedCard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${uid}`, + 'FN:Conflict Lossless', + 'EMAIL:conflict-lossless@example.test', + 'CATEGORIES:Friends,VIP', + 'X-CUSTOM-FLAG:keep-me', + 'TZ:America/New_York', + 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"lossless-1"', importedCard); + fixture.queueSync('', { events: [{ href, etag: '"lossless-1"' }], nextToken: 'lossless-token' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, contactCount: 1 }); + + const { rows: [imported] } = await databaseClient.query(` + SELECT c.id, c.vcard, o.vcard AS mapping_vcard + FROM contacts c + JOIN carddav_remote_objects o ON o.local_contact_id = c.id + WHERE c.user_id = $1 + `, [seeded.userId]); + // Precondition: the local contacts.vcard is lossy; only the mapping is lossless. + expect(imported.vcard).not.toContain('CATEGORIES'); + expect(imported.mapping_vcard).toContain('CATEGORIES:Friends,VIP'); + + // A local edit and a concurrent remote edit make sync raise a conflict. + await databaseClient.query( + 'UPDATE contacts SET organization = $1, updated_at = NOW() WHERE id = $2', + ['Local Edit', imported.id], + ); + const remoteEdit = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:${uid}`, + 'FN:Conflict Remote Edit', + 'EMAIL:conflict-lossless@example.test', + 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"lossless-2"', remoteEdit); + fixture.queueSync('lossless-token', { + events: [{ href, etag: '"lossless-2"' }], + nextToken: 'lossless-token-2', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const { rows: [conflict] } = await databaseClient.query( + `SELECT id, local_vcard, status FROM carddav_conflicts + WHERE user_id = $1 AND href = $2`, + [seeded.userId, href], + ); + expect(conflict.status).toBe('unresolved'); + // The snapshot overlays the current local contact onto the retained remote + // vCard: the local edit is present AND the unmodeled properties survive. + expect(conflict.local_vcard).toContain('ORG:Local Edit'); + expect(conflict.local_vcard).toContain('CATEGORIES:Friends,VIP'); + expect(conflict.local_vcard).toContain('X-CUSTOM-FLAG:keep-me'); + expect(conflict.local_vcard).toContain('TZ:America/New_York'); + + // keep-mailflow pushes that snapshot verbatim, so the unmodeled properties + // reach the remote instead of being stripped. + fixture.reset(); + fixture.putContact(href, '"lossless-2"', remoteEdit); + await carddavConflictService.resolveConflict(seeded.userId, conflict.id, 'keep-mailflow'); + + const putRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(putRequest).toBeDefined(); + expect(putRequest.body).toContain('ORG:Local Edit'); + expect(putRequest.body).toContain('CATEGORIES:Friends,VIP'); + expect(putRequest.body).toContain('X-CUSTOM-FLAG:keep-me'); + expect(putRequest.body).toContain('TZ:America/New_York'); + } finally { + await fixture.close(); + } + }, 120_000); + + it('removes a push-origin contact when its remote resource is deleted', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const { created, mapping } = await createPushOriginContact(fixture, seeded.userId, { + displayName: 'Push Delete', + emails: [{ value: 'push-delete@example.test', type: 'work', primary: true }], + }); + + // Delete the remote resource, then sync with no concurrent local change. + fixture.reset(); + fixture.deleteContact(mapping.href); + fixture.queueSync('', { events: [], nextToken: 'push-delete-token' }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + // The local contact and its mapping are removed once, matching a pull-origin delete. + const { rows: contactRows } = await databaseClient.query( + 'SELECT 1 FROM contacts WHERE id = $1', + [created.id], + ); + expect(contactRows).toEqual([]); + const { rows: mappingRows } = await databaseClient.query(` + SELECT 1 FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + `, [seeded.userId]); + expect(mappingRows).toEqual([]); + // MailFlow must not resurrect the resource it saw deleted. + expect(fixture.counters.create).toBe(0); + expect(fixture.counters.update).toBe(0); + const { rows: conflicts } = await databaseClient.query( + "SELECT 1 FROM carddav_conflicts WHERE user_id = $1 AND status = 'unresolved'", + [seeded.userId], + ); + expect(conflicts).toEqual([]); + } finally { + await fixture.close(); + } + }, 120_000); + + it('rotates the book sync token and advances the served ETag when a remote-only unmodeled change lands', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const href = fixture.href('etag-lifecycle.vcf'); + const first = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:etag-lifecycle-remote', 'FN:Etag Person', + 'EMAIL:etag@example.test', 'CATEGORIES:Alpha', 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"etag-1"', first); + fixture.queueSync('', { events: [{ href, etag: '"etag-1"' }], nextToken: 'etag-token-1' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const servedRow = async () => { + const { rows: [row] } = await databaseClient.query(` + SELECT c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones, + c.organization, c.notes, c.photo_data, c.additional_fields, c.vcard, c.etag, + mapping.vcard AS mapping_vcard, mapping.address_book_id AS book_id + FROM contacts c + JOIN carddav_remote_objects mapping ON mapping.local_contact_id = c.id + WHERE c.user_id = $1 + `, [seeded.userId]); + return row; + }; + const before = await servedRow(); + const bookBefore = await databaseClient.query( + 'SELECT sync_token FROM address_books WHERE id = $1', [before.book_id]); + + // A remote-only change to an UNMODELED property (CATEGORIES): the modeled columns + // and contacts.etag stay identical, but the presented document changes. + const second = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:etag-lifecycle-remote', 'FN:Etag Person', + 'EMAIL:etag@example.test', 'CATEGORIES:Beta', 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"etag-2"', second); + fixture.reset(); + fixture.queueSync('etag-token-1', { events: [{ href, etag: '"etag-2"' }], nextToken: 'etag-token-2' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const after = await servedRow(); + const bookAfter = await databaseClient.query( + 'SELECT sync_token FROM address_books WHERE id = $1', [before.book_id]); + + // Modeled state is unchanged... + expect(after.etag).toBe(before.etag); + // ...but the presented document (hence the served ETag) advanced... + expect(presentedVCard(after)).toContain('CATEGORIES:Beta'); + expect(presentedEtag(after)).not.toBe(presentedEtag(before)); + // ...so the book sync token (getctag) must rotate for pollers to re-fetch. + expect(bookAfter.rows[0].sync_token).not.toBe(bookBefore.rows[0].sync_token); + } finally { + await fixture.close(); + } + }, 120_000); + + it('preserves the remote UID when a Mailflow edit of an email-merged contact syncs, keeping it locally authoritative', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const { rows: [localBook] } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Email Merge Local') RETURNING id + `, [seeded.userId]); + const local = { + uid: 'email-merge-local', + displayName: 'Merge Person', + emails: [{ value: 'email-merge@example.test', type: 'work', primary: true }], + phones: [], organization: 'Local Co', notes: null, photoData: null, + }; + const localVcard = generateVCard(local); + const { rows: [localRow] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, organization, + primary_email, emails, phones, additional_fields, is_auto + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,'[]'::jsonb,'[]'::jsonb,false) + RETURNING id + `, [ + localBook.id, seeded.userId, local.uid, localVcard, + createHash('md5').update(localVcard).digest('hex'), + local.displayName, local.organization, local.emails[0].value, + JSON.stringify(local.emails), + ]); + + // The remote carries a DISTINCT, server-owned UID but the same primary email, + // so the initial sync email-merges it to the local contact (locally authoritative). + const remoteUid = 'email-merge-remote-uid'; + const href = fixture.href('email-merge.vcf'); + const remoteVcard = [ + 'BEGIN:VCARD', 'VERSION:3.0', `UID:${remoteUid}`, 'FN:Merge Person', + 'EMAIL:email-merge@example.test', 'CATEGORIES:Remote', 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"merge-1"', remoteVcard); + fixture.queueSync('', { events: [{ href, etag: '"merge-1"' }], nextToken: 'merge-token-1' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const mappingUidAfterMerge = await databaseClient.query( + 'SELECT vcard FROM carddav_remote_objects o JOIN address_books b ON b.id = o.address_book_id WHERE b.user_id = $1', + [seeded.userId], + ); + expect(parseVCard(mappingUidAfterMerge.rows[0].vcard).uid).toBe(remoteUid); + + // The user edits the merged contact in Mailflow. + fixture.reset(); + fixture.putContact(href, '"merge-1"', remoteVcard); + await carddavContactService.updateContact(seeded.userId, localRow.id, { + organization: 'Edited In Mailflow', + }); + + // The outgoing PUT must keep the remote-owned UID — never coerce it to the local key. + const putRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(putRequest).toBeDefined(); + expect(parseVCard(putRequest.body).uid).toBe(remoteUid); + + const afterEdit = await databaseClient.query( + 'SELECT o.vcard AS mapping_vcard, c.uid AS local_uid FROM carddav_remote_objects o JOIN contacts c ON c.id = o.local_contact_id JOIN address_books b ON b.id = o.address_book_id WHERE b.user_id = $1', + [seeded.userId], + ); + expect(parseVCard(afterEdit.rows[0].mapping_vcard).uid).toBe(remoteUid); + expect(afterEdit.rows[0].local_uid).toBe(local.uid); + + // A subsequent remote delete must only UNLINK an email-merged contact, never + // remove it — provenance stayed distinct through the edit. + fixture.reset(); + fixture.deleteContact(href); + fixture.queueSync('merge-token-1', { events: [{ href, status: 404 }], nextToken: 'merge-token-2' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const survivor = await databaseClient.query( + 'SELECT display_name, organization FROM contacts WHERE id = $1', + [localRow.id], + ); + expect(survivor.rows).toEqual([ + { display_name: 'Merge Person', organization: 'Edited In Mailflow' }, + ]); + } finally { + await fixture.close(); + } + }, 120_000); + + it('keeps the remote UID when keep-mailflow resolves an email-merged conflict, staying locally authoritative', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const { rows: [localBook] } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Email Merge Conflict Local') RETURNING id + `, [seeded.userId]); + const local = { + uid: 'em-conflict-local', + displayName: 'Merge Conflict', + emails: [{ value: 'em-conflict@example.test', type: 'work', primary: true }], + phones: [], organization: 'Local Co', notes: null, photoData: null, + }; + const localVcard = generateVCard(local); + const { rows: [localRow] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, organization, + primary_email, emails, phones, additional_fields, is_auto + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,'[]'::jsonb,'[]'::jsonb,false) + RETURNING id + `, [ + localBook.id, seeded.userId, local.uid, localVcard, + createHash('md5').update(localVcard).digest('hex'), + local.displayName, local.organization, local.emails[0].value, + JSON.stringify(local.emails), + ]); + + const remoteUid = 'em-conflict-remote-uid'; + const href = fixture.href('em-conflict.vcf'); + const remoteVcard = [ + 'BEGIN:VCARD', 'VERSION:3.0', `UID:${remoteUid}`, 'FN:Merge Conflict', + 'EMAIL:em-conflict@example.test', 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"emc-1"', remoteVcard); + fixture.queueSync('', { events: [{ href, etag: '"emc-1"' }], nextToken: 'emc-token-1' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + // Concurrent local edit + remote edit → sync raises a conflict for this email-merged + // contact, snapshotting the local vCard that keep-mailflow will push verbatim. + await databaseClient.query( + 'UPDATE contacts SET organization = $1, updated_at = NOW() WHERE id = $2', + ['Local Edit', localRow.id], + ); + const remoteEdit = [ + 'BEGIN:VCARD', 'VERSION:3.0', `UID:${remoteUid}`, 'FN:Remote Edit', + 'EMAIL:em-conflict@example.test', 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"emc-2"', remoteEdit); + fixture.queueSync('emc-token-1', { events: [{ href, etag: '"emc-2"' }], nextToken: 'emc-token-2' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const { rows: [conflict] } = await databaseClient.query( + `SELECT id FROM carddav_conflicts WHERE user_id = $1 AND href = $2 AND status = 'unresolved'`, + [seeded.userId, href], + ); + expect(conflict).toBeDefined(); + + // keep-mailflow pushes the snapshot; it must carry the ORIGINAL remote UID. + fixture.reset(); + fixture.putContact(href, '"emc-2"', remoteEdit); + await carddavConflictService.resolveConflict(seeded.userId, conflict.id, 'keep-mailflow'); + + const putRequest = fixture.requests.find(request => request.method === 'PUT'); + expect(putRequest).toBeDefined(); + expect(parseVCard(putRequest.body).uid).toBe(remoteUid); + + // The mapping keeps the remote UID and the contact keeps its local key, so it + // remains locally authoritative — a later remote delete only unlinks it. + const afterResolve = await databaseClient.query( + 'SELECT o.vcard AS mapping_vcard, c.uid AS local_uid FROM carddav_remote_objects o JOIN contacts c ON c.id = o.local_contact_id JOIN address_books b ON b.id = o.address_book_id WHERE b.user_id = $1', + [seeded.userId], + ); + expect(parseVCard(afterResolve.rows[0].mapping_vcard).uid).toBe(remoteUid); + expect(afterResolve.rows[0].local_uid).toBe(local.uid); + + fixture.reset(); + fixture.deleteContact(href); + fixture.queueSync('emc-token-2', { events: [{ href, status: 404 }], nextToken: 'emc-token-3' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + const survivor = await databaseClient.query('SELECT 1 FROM contacts WHERE id = $1', [localRow.id]); + expect(survivor.rows).toEqual([{ '?column?': 1 }]); + } finally { + await fixture.close(); + } + }, 120_000); + + it('applies a 507-truncated multi-page sync as one delta and never drops unlisted contacts', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + // An existing local contact, imported by a first sync, that no later page mentions. + await seedSingleRemoteContact(fixture, seeded.userId, { + uid: 'multipage-survivor', name: 'Survivor', token: 'multipage-token-1', + }); + + // A remote delta delivered across TWO pages: page 1 is 507-truncated with a + // continuation token, page 2 completes it. + const hrefA = fixture.href('multipage-a.vcf'); + const hrefB = fixture.href('multipage-b.vcf'); + fixture.putContact(hrefA, '"a-1"', remoteVcard('multipage-a', 'Page One')); + fixture.putContact(hrefB, '"b-1"', remoteVcard('multipage-b', 'Page Two')); + fixture.queueSync('multipage-token-1', { + events: [{ href: hrefA, etag: '"a-1"' }], + nextToken: 'multipage-page-2', + truncated: true, + }); + fixture.queueSync('multipage-page-2', { + events: [{ href: hrefB, etag: '"b-1"' }], + nextToken: 'multipage-token-final', + }); + + const before507 = fixture.counters.requestUri507; + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + // The 507-truncated page was actually exercised as one welded delta. + expect(fixture.counters.requestUri507).toBe(before507 + 1); + const state = await projectionState(seeded.userId); + const names = state.contacts.map(contact => contact.display_name).sort(); + // Both pages' contacts imported AND the unlisted survivor is never dropped. + expect(names).toEqual(['Page One', 'Page Two', 'Survivor']); + // The remote token advances only once, to the final page's token. + const book = state.books.find(row => row.source === 'carddav'); + expect(book.remote_sync_token).toBe('multipage-token-final'); + } finally { + await fixture.close(); + } + }, 120_000); + + it('retains an owned resolved tombstone for repeat 409 and fixed cleanup', async () => { + const fixture = createCarddavFixtureServer(); + let apiServer; + await fixture.listen(); + try { + const owner = await seedConnectedUser(fixture); + const foreign = await seedConnectedUser(fixture); + const seeded = await seedResolutionConflict(fixture, owner.userId, { + uid: 'retained-tombstone', + remoteTombstone: true, + }); + fixture.reset(); + apiServer = await listenConflictApi(); + const origin = `http://127.0.0.1:${apiServer.address().port}`; + const resolve = userId => fetch( + `${origin}/conflicts/${seeded.conflictId}/resolve`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Test-User-Id': userId, + }, + body: JSON.stringify({ resolution: 'keep-carddav' }), + }, + ); + + const first = await resolve(owner.userId); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ + id: seeded.conflictId, + status: 'resolved', + resolution: 'keep-carddav', + remote: { tombstone: true }, + }); + expect(fixture.counters).toMatchObject({ fetch: 1, update: 0, delete: 0 }); + + const { rows: [retained] } = await databaseClient.query(` + SELECT conflict.status, conflict.resolution, + conflict.resolved_at IS NOT NULL AS resolved, + mapping.href AS mapping_href, contact.id AS contact_id + FROM carddav_conflicts conflict + LEFT JOIN carddav_remote_objects mapping + ON mapping.address_book_id = conflict.address_book_id + AND mapping.href = conflict.href + LEFT JOIN contacts contact ON contact.id = $2 + WHERE conflict.id = $1 + `, [seeded.conflictId, seeded.local_contact_id]); + expect(retained).toEqual({ + status: 'resolved', + resolution: 'keep-carddav', + resolved: true, + mapping_href: null, + contact_id: null, + }); + + const detail = await fetch(`${origin}/conflicts/${seeded.conflictId}`, { + headers: { 'X-Test-User-Id': owner.userId }, + }); + expect(detail.status).toBe(200); + expect(await detail.json()).toMatchObject({ + id: seeded.conflictId, + status: 'resolved', + }); + expect((await resolve(foreign.userId)).status).toBe(404); + expect((await resolve(owner.userId)).status).toBe(409); + + await carddavConflictService.deleteResolvedConflictsBefore( + databaseClient, + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + ); + const recent = await databaseClient.query( + 'SELECT id FROM carddav_conflicts WHERE id = $1', + [seeded.conflictId], + ); + expect(recent.rowCount).toBe(1); + await databaseClient.query( + "UPDATE carddav_conflicts SET resolved_at = NOW() - INTERVAL '31 days' WHERE id = $1", + [seeded.conflictId], + ); + await carddavConflictService.deleteResolvedConflictsBefore( + databaseClient, + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + ); + const expired = await databaseClient.query( + 'SELECT id FROM carddav_conflicts WHERE id = $1', + [seeded.conflictId], + ); + expect(expired.rowCount).toBe(0); + } finally { + if (apiServer) await closeServer(apiServer); + await fixture.close(); + } + }, 120_000); + + it('retains an aged unresolved conflict through the resolved-only cleanup', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + try { + const seededUser = await seedConnectedUser(fixture); + const seeded = await seedResolutionConflict(fixture, seededUser.userId, { + uid: 'aged-unresolved', + remoteCard: remoteVcard('aged-unresolved', 'Aged Unresolved Remote'), + }); + // Age the still-unresolved conflict well past the 30-day retention window; + // it must survive because the cleanup only deletes resolved rows. + await databaseClient.query(` + UPDATE carddav_conflicts + SET created_at = NOW() - INTERVAL '60 days', updated_at = NOW() - INTERVAL '60 days' + WHERE id = $1 + `, [seeded.conflictId]); + + await carddavConflictService.deleteResolvedConflictsBefore( + databaseClient, + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + ); + + const { rows } = await databaseClient.query( + 'SELECT status, resolved_at FROM carddav_conflicts WHERE id = $1', + [seeded.conflictId], + ); + expect(rows).toEqual([{ status: 'unresolved', resolved_at: null }]); + } finally { + await fixture.close(); + } + }, 120_000); + + it('reports URI PHOTO presence through the real conflict request boundary', async () => { + const fixture = createCarddavFixtureServer(); + let apiServer; + await fixture.listen(); + try { + const seededUser = await seedConnectedUser(fixture); + const uri = 'https://images.example.test/private.jpg'; + const remoteCard = remotePhotoVcard( + 'uri-photo-conflict', + 'URI Photo Conflict', + 'uri-photo@example.test', + `PHOTO;VALUE=URI:${uri}`, + ); + const seeded = await seedResolutionConflict(fixture, seededUser.userId, { + uid: 'uri-photo-conflict', + remoteCard, + }); + apiServer = await listenConflictApi(); + const response = await fetch( + `http://127.0.0.1:${apiServer.address().port}/conflicts/${seeded.conflictId}`, + { headers: { 'X-Test-User-Id': seededUser.userId } }, + ); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(JSON.parse(body)).toMatchObject({ + id: seeded.conflictId, + remote: { tombstone: false, hasPhoto: true }, + }); + expect(body).not.toContain(uri); + expect(body).not.toContain('BEGIN:VCARD'); + } finally { + if (apiServer) await closeServer(apiServer); + await fixture.close(); + } + }, 120_000); + + it('rolls back contact and mapping state when the conflict transition loses its CAS', async () => { + const fixture = createCarddavFixtureServer(); + let apiServer; + await fixture.listen(); + try { + const seededUser = await seedConnectedUser(fixture); + const remoteCard = remoteVcard('resolution-cas', 'Resolution CAS Remote'); + const seeded = await seedResolutionConflict(fixture, seededUser.userId, { + uid: 'resolution-cas', + remoteCard, + }); + await databaseClient.query(` + CREATE FUNCTION force_conflict_resolution_cas_miss() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + RETURN NULL; + END; + $$ + `); + await databaseClient.query(` + CREATE TRIGGER force_conflict_resolution_cas_miss + BEFORE UPDATE OF status ON carddav_conflicts + FOR EACH ROW + WHEN (OLD.status = 'unresolved' AND NEW.status = 'resolved') + EXECUTE FUNCTION force_conflict_resolution_cas_miss() + `); + fixture.reset(); + apiServer = await listenConflictApi(); + const response = await fetch( + `http://127.0.0.1:${apiServer.address().port}/conflicts/${seeded.conflictId}/resolve`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Test-User-Id': seededUser.userId, + }, + body: JSON.stringify({ resolution: 'keep-carddav' }), + }, + ); + + expect(response.status).toBe(409); + const { rows: [state] } = await databaseClient.query(` + SELECT mapping.mapping_revision::text, mapping.mapping_status, + conflict.status, conflict.resolution, conflict.resolved_at, + contact.display_name + FROM carddav_remote_objects mapping + JOIN carddav_conflicts conflict + ON conflict.address_book_id = mapping.address_book_id + AND conflict.href = mapping.href + JOIN contacts contact ON contact.id = mapping.local_contact_id + WHERE conflict.id = $1 + `, [seeded.conflictId]); + expect(state).toEqual({ + mapping_revision: seeded.mappingRevision, + mapping_status: 'conflict', + status: 'unresolved', + resolution: null, + resolved_at: null, + display_name: seeded.local_display_name, + }); + expect(fixture.counters).toMatchObject({ fetch: 1, update: 0, delete: 0 }); + } finally { + await databaseClient.query( + 'DROP TRIGGER IF EXISTS force_conflict_resolution_cas_miss ON carddav_conflicts', + ); + await databaseClient.query('DROP FUNCTION IF EXISTS force_conflict_resolution_cas_miss()'); + if (apiServer) await closeServer(apiServer); + await fixture.close(); + } + }, 120_000); + + it('recovers old pending update and delete intents after restart without replaying writes', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const initial = await seedSingleRemoteContact(fixture, seeded.userId, { + uid: 'restart-recovery', + name: 'Before Restart', + token: 'restart-recovery-1', + }); + const { rows: [before] } = await databaseClient.query(` + SELECT o.address_book_id, o.href, o.local_contact_id, + o.local_contact_hash, o.mapping_revision::text, + c.uid + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + WHERE c.user_id = $1 AND o.href = $2 + `, [seeded.userId, initial.href]); + const attemptedVCard = remoteVcard( + before.uid, + 'Recovered After Restart', + 'restart-recovery@example.test', + ); + fixture.putContact(initial.href, '"restart-recovery-2"', attemptedVCard); + await databaseClient.query(` + UPDATE carddav_remote_objects SET + mapping_status = 'pending_push', + pending_operation = 'update', pending_vcard = $1, + pending_local_hash = $2, pending_remote_semantic_hash = $3, + pending_started_at = '2000-01-01T00:00:00Z', + mapping_revision = mapping_revision + 1 + WHERE address_book_id = $4 AND href = $5 + `, [ + attemptedVCard, + before.local_contact_hash, + semanticVCardHash(parseVCardDocument(attemptedVCard)), + before.address_book_id, + before.href, + ]); + + fixture.reset(); + fixture.queueSync('restart-recovery-1', { + events: [], + nextToken: 'restart-recovery-2', + }); + await expect(carddavSync.syncUser(seeded.userId)).resolves.toMatchObject({ ok: true }); + + expect(fixture.requests.filter(request => request.method === 'PUT')).toHaveLength(0); + expect(fixture.requests.filter(request => request.method === 'DELETE')).toHaveLength(0); + expect(fixture.requests.filter(request => request.method === 'GET')).toHaveLength(1); + const { rows: [updated] } = await databaseClient.query(` + SELECT o.mapping_status, o.pending_operation, o.pending_vcard, + o.pending_local_hash, o.pending_remote_semantic_hash, + o.pending_started_at, o.local_contact_hash, + c.display_name + FROM carddav_remote_objects o + JOIN contacts c ON c.id = o.local_contact_id + WHERE c.user_id = $1 AND o.href = $2 + `, [seeded.userId, initial.href]); + expect(updated).toMatchObject({ + mapping_status: 'synced', + pending_operation: null, + pending_vcard: null, + pending_local_hash: null, + pending_remote_semantic_hash: null, + pending_started_at: null, + display_name: 'Recovered After Restart', + }); + + await databaseClient.query(` + UPDATE carddav_remote_objects SET + mapping_status = 'pending_push', + pending_operation = 'delete', pending_vcard = NULL, + pending_local_hash = $1, pending_remote_semantic_hash = NULL, + pending_started_at = '2000-01-01T00:00:00Z', + mapping_revision = mapping_revision + 1 + WHERE address_book_id = $2 AND href = $3 + `, [updated.local_contact_hash, before.address_book_id, before.href]); + fixture.deleteContact(initial.href); + fixture.reset(); + fixture.queueSync('restart-recovery-2', { + events: [], + nextToken: 'restart-recovery-3', + }); + + await expect(carddavSync.syncUser(seeded.userId)).resolves.toMatchObject({ ok: true }); + + expect(fixture.requests.filter(request => request.method === 'PUT')).toHaveLength(0); + expect(fixture.requests.filter(request => request.method === 'DELETE')).toHaveLength(0); + expect(fixture.requests.filter(request => request.method === 'GET')).toHaveLength(1); + const { rows: [deleted] } = await databaseClient.query(` + SELECT + (SELECT COUNT(*)::int FROM carddav_remote_objects + WHERE address_book_id = $1 AND href = $2) AS mappings, + (SELECT COUNT(*)::int FROM contacts + WHERE user_id = $3 AND id = $4) AS contacts + `, [before.address_book_id, before.href, seeded.userId, before.local_contact_id]); + expect(deleted).toEqual({ mappings: 0, contacts: 0 }); + await fixture.close(); + }, 120_000); + + it('preserves the visible count across a same-identity password replacement and no-change delta', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const contact = await seedSingleRemoteContact(fixture, seeded.userId, { + uid: 'password-count', + name: 'Password Count', + token: 'password-count-1', + }); + + const replacement = await carddavSync.replaceCarddavConnection(seeded.userId, { + serverUrl: fixture.serverUrl, + username: 'fixture-user', + password: encrypt('replacement-password'), + intervalMin: 60, + }); + fixture.reset(); + fixture.queueSync(contact.token, { events: [], nextToken: 'password-count-2' }); + + const result = await carddavSync.syncUser(seeded.userId); + const state = await projectionState(seeded.userId); + const [integration] = await integrationState(seeded.userId); + + expect(result).toMatchObject({ ok: true, contactCount: 1 }); + expect(state.contacts).toHaveLength(1); + expect(state.ledger).toHaveLength(1); + expect(integration.config).toMatchObject({ + connectionGeneration: replacement.connectionGeneration, + contactCount: 1, + }); + await fixture.close(); + }, 120_000); + + it('keeps the cached count aligned when one book commits before the next book fails', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const twoBooks = await seedTwoRemoteBooks(fixture, seeded.userId); + const changedCardB = remoteVcard('person-b', 'Person B Changed'); + fixture.deleteContact(twoBooks.hrefA); + fixture.putContact(twoBooks.hrefB, '"person-b-2"', changedCardB); + fixture.reset(); + fixture.queueDiscovery({ books: [ + { href: twoBooks.bookAPath, displayName: 'Contacts A' }, + { href: twoBooks.bookBPath, displayName: 'Contacts B' }, + ] }); + fixture.queueSync('two-book-a-1', { + events: [{ href: twoBooks.hrefA, status: 404 }], + nextToken: 'two-book-a-2', + }); + fixture.queueSync('two-book-b-1', { + events: [{ href: twoBooks.hrefB, etag: '"person-b-2"' }], + nextToken: 'two-book-b-2', + }); + await databaseClient.query(` + CREATE FUNCTION fail_second_count_book() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.id = '${twoBooks.bookB.id}'::uuid THEN + RAISE EXCEPTION 'forced second count book failure'; + END IF; + RETURN NEW; + END + $$ + `); + await databaseClient.query(` + CREATE TRIGGER fail_second_count_book + BEFORE UPDATE ON address_books + FOR EACH ROW EXECUTE FUNCTION fail_second_count_book() + `); + + const failed = await carddavSync.syncUser(seeded.userId); + await databaseClient.query('DROP TRIGGER fail_second_count_book ON address_books'); + await databaseClient.query('DROP FUNCTION fail_second_count_book()'); + const failedState = await projectionState(seeded.userId); + const [failedIntegration] = await integrationState(seeded.userId); + + expect(failed).toMatchObject({ + ok: false, + error: 'forced second count book failure', + }); + expect(failedState.contacts).toHaveLength(1); + expect(failedState.ledger).toHaveLength(1); + expect(failedIntegration.config.contactCount).toBe(1); + + fixture.reset(); + fixture.queueDiscovery({ books: [ + { href: twoBooks.bookAPath, displayName: 'Contacts A' }, + { href: twoBooks.bookBPath, displayName: 'Contacts B' }, + ] }); + fixture.queueSync('two-book-a-2', { events: [], nextToken: 'two-book-a-3' }); + fixture.queueSync('two-book-b-1', { + events: [{ href: twoBooks.hrefB, etag: '"person-b-2"' }], + nextToken: 'two-book-b-2', + }); + + const retried = await carddavSync.syncUser(seeded.userId); + const retriedState = await projectionState(seeded.userId); + const [retriedIntegration] = await integrationState(seeded.userId); + + expect(retried).toMatchObject({ ok: true, contactCount: 1 }); + expect(retriedState.contacts).toHaveLength(1); + expect(retriedState.ledger).toHaveLength(1); + expect(retriedIntegration.config.contactCount).toBe(1); + await fixture.close(); + }, 120_000); + + it('returns the authoritative count after retaining one discovered book and pruning another', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const twoBooks = await seedTwoRemoteBooks(fixture, seeded.userId); + fixture.reset(); + fixture.queueDiscovery({ books: [ + { href: twoBooks.bookAPath, displayName: 'Contacts A' }, + ] }); + fixture.queueSync('two-book-a-1', { events: [], nextToken: 'two-book-a-2' }); + + const result = await carddavSync.syncUser(seeded.userId); + const state = await projectionState(seeded.userId); + const [integration] = await integrationState(seeded.userId); + + expect(result).toMatchObject({ ok: true, bookCount: 1, contactCount: 1 }); + expect(state.books.filter(book => book.source === 'carddav')).toHaveLength(1); + expect(state.contacts).toHaveLength(1); + expect(state.ledger).toHaveLength(1); + expect(integration.config.contactCount).toBe(1); + await fixture.close(); + }, 120_000); + + it('snapshot discovery without sync-collection sends exactly one CardDAV filter', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const href = fixture.href('snapshot-only.vcf'); + const card = remoteVcard('snapshot-only', 'Snapshot Only'); + fixture.putContact(href, '"snapshot-only-1"', card); + fixture.queueDiscovery({ books: [{ + href: '/addressbooks/fixture-user/contacts/', + displayName: 'Snapshot Only', + reports: false, + }] }); + + expect(await carddavSync.syncUser(seeded.userId)).toEqual({ + ok: true, + bookCount: 1, + contactCount: 1, + remote: 1, + fetched: 1, + updated: 1, + removed: 0, + fallback: 1, + exportFailures: [], + }); + const state = await projectionState(seeded.userId); + expect(state.books).toHaveLength(1); + expect(state.books[0]).toMatchObject({ + source: 'carddav', + remote_sync_token: null, + remote_sync_capability: 'snapshot', + remote_sync_revision: '1', + }); + expect(state.contacts).toHaveLength(1); + expect(state.contacts[0].primary_email).toBe('snapshot-only@example.test'); + expect(state.ledger).toHaveLength(1); + expect(fixture.counters).toMatchObject({ + requests: 4, + propfind: 3, + sync: 0, + multiget: 0, + addressbookQuery: 1, + snapshotFilters: [1], + }); + await fixture.close(); + }, 120_000); + + it('valid empty discovery prunes projection while malformed empty discovery changes only status', async () => { + const emptyFixture = createCarddavFixtureServer(); + await emptyFixture.listen(); + const emptySeeded = await seedConnectedUser(emptyFixture); + await seedSingleRemoteContact(emptyFixture, emptySeeded.userId); + const unrelatedBook = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Empty Discovery Unrelated') RETURNING id, sync_token + `, [emptySeeded.userId]); + emptyFixture.reset(); + emptyFixture.queueDiscovery({ books: [] }); + + expect(await carddavSync.syncUser(emptySeeded.userId)).toEqual({ + ok: true, + bookCount: 0, + contactCount: 0, + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, + exportFailures: [], + }); + expect(await projectionState(emptySeeded.userId)).toEqual({ + books: [{ + id: unrelatedBook.rows[0].id, + source: 'local', + external_url: null, + sync_token: unrelatedBook.rows[0].sync_token, + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: '0', + remote_projection_fingerprint: null, + }], + contacts: [], + ledger: [], + }); + const [emptyIntegration] = await integrationState(emptySeeded.userId); + expect(emptyIntegration.config).toEqual({ + ...emptySeeded.config, + lastError: null, + lastSyncAt: expect.any(String), + bookCount: 0, + contactCount: 0, + exportFailures: [], + }); + expect(emptyFixture.counters).toMatchObject({ + requests: 3, + propfind: 3, + sync: 0, + multiget: 0, + addressbookQuery: 0, + }); + await emptyFixture.close(); + + const malformedFixture = createCarddavFixtureServer(); + await malformedFixture.listen(); + const malformedSeeded = await seedConnectedUser(malformedFixture); + await seedSingleRemoteContact(malformedFixture, malformedSeeded.userId); + const malformedBefore = await projectionState(malformedSeeded.userId, { timestamps: true }); + malformedFixture.reset(); + malformedFixture.queueDiscovery({ + rawBody: '', + }); + + const malformed = await carddavSync.syncUser(malformedSeeded.userId); + + expect(malformed.ok).toBe(false); + expect(malformed.error).toMatch(/home collection|home-set|multistatus/i); + expect(await projectionState(malformedSeeded.userId, { timestamps: true })) + .toEqual(malformedBefore); + const [malformedIntegration] = await integrationState(malformedSeeded.userId); + expect(malformedIntegration.config).toEqual({ + ...malformedSeeded.config, + lastError: malformed.error, + lastSyncAt: expect.any(String), + bookCount: 1, + contactCount: 1, + exportFailures: [], + }); + expect(malformedFixture.counters).toMatchObject({ + requests: 3, + propfind: 3, + sync: 0, + multiget: 0, + addressbookQuery: 0, + }); + await malformedFixture.close(); + }, 120_000); + + it('duplicate discovery aliases collapse once and conflicting duplicate metadata performs no plan', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + fixture.queueDiscovery({ books: [ + { href: '/addressbooks/fixture-user/contacts/', displayName: 'Fixture Contacts' }, + { href: '/addressbooks/fixture-user/contacts/./', displayName: 'Fixture Contacts' }, + ] }); + fixture.queueSync('', { events: [], nextToken: 'duplicate-token' }); + + expect(await carddavSync.syncUser(seeded.userId)).toEqual({ + ok: true, + bookCount: 1, + contactCount: 0, + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, + exportFailures: [], + }); + const duplicateState = await projectionState(seeded.userId); + expect(duplicateState.books).toHaveLength(1); + expect(duplicateState.books[0]).toMatchObject({ + source: 'carddav', + external_url: fixture.href(''), + remote_sync_token: 'duplicate-token', + remote_sync_revision: '1', + }); + expect(duplicateState.contacts).toEqual([]); + expect(duplicateState.ledger).toEqual([]); + expect(fixture.counters).toMatchObject({ + requests: 4, + propfind: 3, + sync: 1, + multiget: 0, + syncTokens: [''], + }); + await fixture.close(); + + const conflictFixture = createCarddavFixtureServer(); + await conflictFixture.listen(); + const conflictSeeded = await seedConnectedUser(conflictFixture); + await seedSingleRemoteContact(conflictFixture, conflictSeeded.userId); + const before = await projectionState(conflictSeeded.userId, { timestamps: true }); + conflictFixture.reset(); + conflictFixture.queueDiscovery({ books: [ + { href: '/addressbooks/fixture-user/contacts/', displayName: 'First Name' }, + { href: '/addressbooks/fixture-user/contacts/./', displayName: 'Conflicting Name' }, + ] }); + + const conflict = await carddavSync.syncUser(conflictSeeded.userId); + + expect(conflict.ok).toBe(false); + expect(conflict.error).toMatch(/conflicting.*metadata|metadata.*conflict/i); + expect(await projectionState(conflictSeeded.userId, { timestamps: true })).toEqual(before); + expect(conflictFixture.counters).toMatchObject({ + requests: 3, + propfind: 3, + sync: 0, + multiget: 0, + }); + await conflictFixture.close(); + }, 120_000); + + it('alias redirect performs full reconciliation and preserves the production book identity', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const aliasPath = '/addressbooks/fixture-user/alias/'; + const canonicalPath = '/addressbooks/fixture-user/canonical/'; + const aliasUrl = new URL(aliasPath, fixture.serverUrl).href; + const canonicalUrl = new URL(canonicalPath, fixture.serverUrl).href; + const aliasHref = new URL('person.vcf', aliasUrl).href; + const canonicalHref = new URL('person.vcf', canonicalUrl).href; + const card = remoteVcard('alias-person', 'Alias Person'); + fixture.queueDiscovery({ books: [{ href: aliasPath, displayName: 'Alias Book' }] }); + fixture.putContact(aliasHref, '"alias-1"', card); + fixture.queueSync('', { + events: [{ href: aliasHref, etag: '"alias-1"' }], + nextToken: 'alias-token', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + const before = await projectionState(seeded.userId); + const beforeBook = before.books.find(row => row.source === 'carddav'); + expect(beforeBook.external_url).toBe(aliasUrl); + + fixture.deleteContact(aliasHref); + fixture.putContact(canonicalHref, '"canonical-1"', card); + fixture.reset(); + fixture.queueDiscovery({ books: [{ href: aliasPath, displayName: 'Alias Book' }] }); + fixture.queueRedirect('REPORT', aliasPath, canonicalPath); + fixture.queueSync('alias-token', { events: [], nextToken: 'redirect-intermediate' }); + fixture.queueSync('', { + events: [{ rawHref: 'person.vcf', etag: '"canonical-1"' }], + nextToken: 'canonical-token', + }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, bookCount: 1, contactCount: 1, fallback: 1, + }); + const after = await projectionState(seeded.userId); + const afterBook = after.books[0]; + expect(after.books).toEqual([{ + ...beforeBook, + external_url: canonicalUrl, + sync_token: afterBook.sync_token, + remote_sync_token: 'canonical-token', + remote_sync_revision: '2', + }]); + expect(afterBook.sync_token).not.toBe(beforeBook.sync_token); + expect(after.contacts).toHaveLength(1); + const afterContact = after.contacts[0]; + expect(afterContact).toEqual(expectedLocalContact( + canonicalHref, card, beforeBook.id, seeded.userId, afterContact.id, + )); + expect(after.ledger).toEqual([{ + address_book_id: beforeBook.id, + href: canonicalHref, + remote_etag: '"canonical-1"', + vcard: card, + primary_email: 'alias-person@example.test', + local_contact_id: afterContact.id, + }]); + expect(fixture.requests.map(request => `${request.method} ${request.path}`)).toEqual([ + 'PROPFIND /', + 'PROPFIND /principals/fixture-user/', + 'PROPFIND /addressbooks/fixture-user/', + `REPORT ${aliasPath}`, + `REPORT ${canonicalPath}`, + `REPORT ${canonicalPath}`, + `REPORT ${canonicalPath}`, + ]); + expect(fixture.counters).toMatchObject({ + requests: 7, + propfind: 3, + sync: 2, + multiget: 1, + syncTokens: ['alias-token', ''], + multigetSizes: [1], + }); + await fixture.close(); + }, 120_000); + + it('disconnect during paused planning restores merges and prevents old work from recreating projection', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const { rows: [localBook] } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Disconnect Local') RETURNING id, sync_token + `, [seeded.userId]); + const localContact = { + uid: 'disconnect-local', + displayName: 'Disconnect Original', + firstName: 'Disconnect', + lastName: 'Original', + emails: [{ value: 'disconnect@example.test', type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + }; + const localVcard = generateVCard(localContact); + const { rows: [insertedContact] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + last_name, primary_email, emails, phones, organization, notes, photo_data + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '[]'::jsonb, NULL, NULL, NULL + ) RETURNING id + `, [ + localBook.id, + seeded.userId, + localContact.uid, + localVcard, + createHash('md5').update(localVcard).digest('hex'), + localContact.displayName, + localContact.firstName, + localContact.lastName, + localContact.emails[0].value, + JSON.stringify(localContact.emails), + ]); + const href = fixture.href('disconnect.vcf'); + const remoteCard = remoteVcard('disconnect-remote', 'Disconnect Remote', 'disconnect@example.test'); + fixture.putContact(href, '"disconnect-1"', remoteCard); + fixture.queueSync('', { + events: [{ href, etag: '"disconnect-1"' }], + nextToken: 'disconnect-token', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, + }); + const linked = await projectionState(seeded.userId); + expect(linked.ledger[0].local_contact_id).toBe(insertedContact.id); + expect(linked.contacts.find(row => row.id === insertedContact.id).display_name) + .toBe('Disconnect Original'); + + const barrier = deferred(); + const reached = deferred(); + fixture.reset(); + fixture.queueSync('disconnect-token', { + events: [], + nextToken: 'old-disconnect-plan', + waitFor: barrier.promise, + reached: reached.resolve, + }); + const pending = carddavSync.syncUser(seeded.userId); + await reached.promise; + expect(await carddavSync.disconnectCarddavAccount(seeded.userId)).toBe(true); + const disconnected = await projectionState(seeded.userId); + expect(disconnected.books).toEqual([{ + id: localBook.id, + source: 'local', + external_url: null, + sync_token: disconnected.books[0].sync_token, + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: '0', + remote_projection_fingerprint: null, + }]); + expect(disconnected.books[0].sync_token).toBe(localBook.sync_token); + expect(disconnected.contacts).toEqual([{ + id: insertedContact.id, + address_book_id: localBook.id, + user_id: seeded.userId, + uid: localContact.uid, + vcard: localVcard, + etag: createHash('md5').update(localVcard).digest('hex'), + display_name: localContact.displayName, + first_name: localContact.firstName, + last_name: localContact.lastName, + primary_email: localContact.emails[0].value, + emails: localContact.emails, + phones: [], + organization: null, + notes: null, + photo_data: null, + }]); + expect(disconnected.ledger).toEqual([]); + expect(await integrationState(seeded.userId)).toEqual([]); + barrier.resolve(); + + expect(await pending).toMatchObject({ ok: false }); + expect(await projectionState(seeded.userId)).toEqual(disconnected); + expect(await integrationState(seeded.userId)).toEqual([]); + expect(fixture.counters).toMatchObject({ + requests: 4, + propfind: 3, + sync: 1, + multiget: 0, + syncTokens: ['disconnect-token'], + multigetSizes: [], + }); + await fixture.close(); + }, 120_000); + + it('photo-only sync persists bytes and production CardDAV GET and REPORT expose the same vCard and ETag', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const href = fixture.href('photo.vcf'); + const jpegCard = remotePhotoVcard( + 'photo', 'Photo Contact', 'photo@example.test', 'PHOTO;ENCODING=b;TYPE=JPEG:AQID', + ); + fixture.putContact(href, '"photo-1"', jpegCard); + fixture.queueSync('', { + events: [{ href, etag: '"photo-1"' }], + nextToken: 'photo-token-1', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 1 }); + const jpegState = await projectionState(seeded.userId); + const jpegBook = jpegState.books.find(row => row.source === 'carddav'); + expect(jpegState.contacts[0].photo_data).toBe('data:image/jpeg;base64,AQID'); + expect(jpegState.contacts[0].vcard).toContain('PHOTO;ENCODING=b;TYPE=JPEG:AQID\r\n'); + + const pngCard = remotePhotoVcard( + 'photo', 'Photo Contact', 'photo@example.test', 'PHOTO;ENCODING=b;TYPE=PNG:BAUG', + ); + fixture.putContact(href, '"photo-2"', pngCard); + fixture.reset(); + fixture.queueSync('photo-token-1', { + events: [{ href, etag: '"photo-2"' }], + nextToken: 'photo-token-2', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 1 }); + const pngState = await projectionState(seeded.userId); + const pngBook = pngState.books.find(row => row.id === jpegBook.id); + const pngContact = pngState.contacts[0]; + expect(pngContact.photo_data).toBe('data:image/png;base64,BAUG'); + expect(pngContact.vcard).toContain('PHOTO;ENCODING=b;TYPE=PNG:BAUG\r\n'); + expect(pngContact.etag).toBe(createHash('md5').update(pngContact.vcard).digest('hex')); + expect(pngContact.etag).not.toBe(jpegState.contacts[0].etag); + expect(pngBook.sync_token).not.toBe(jpegBook.sync_token); + + await databaseClient.query( + 'UPDATE users SET password_hash = $2 WHERE id = $1', + [seeded.userId, await bcrypt.hash('carddav-output-password', 4)], + ); + const app = express(); + const { default: carddavRouter } = await import('../routes/carddav.js'); + app.use('/carddav', carddavRouter); + const outputServer = await listenOnLocalhost(app); + const outputOrigin = `http://127.0.0.1:${outputServer.address().port}`; + const authorization = `Basic ${Buffer.from( + `carddav-e2e-${seeded.userId}:carddav-output-password`, + ).toString('base64')}`; + const cardPath = `/carddav/${seeded.userId}/${pngBook.id}/${encodeURIComponent(pngContact.uid)}.vcf`; + // The CardDAV server serves the retained remote document overlaid with the local + // contact (presentedVCard), so the photo round-trips losslessly. The ETag stays + // the local contacts.etag. + const { rows: [pngRow] } = await databaseClient.query(` + SELECT c.uid, c.display_name, c.first_name, c.last_name, c.emails, c.phones, + c.organization, c.notes, c.photo_data, c.additional_fields, c.vcard, + mapping.vcard AS mapping_vcard + FROM contacts c + LEFT JOIN carddav_remote_objects mapping + ON mapping.local_contact_id = c.id + AND mapping.mapping_status <> 'pending_materialization' + WHERE c.id = $1 + `, [pngContact.id]); + const presentedPng = presentedVCard(pngRow); + const servedEtag = `"${presentedEtag(pngRow)}"`; + expect(presentedPng).toContain('PHOTO;ENCODING=b;TYPE=PNG:BAUG'); + const getResponse = await fetch(`${outputOrigin}${cardPath}`, { + headers: { Authorization: authorization }, + }); + expect(getResponse.status).toBe(200); + // The served ETag derives from the presented document. + expect(getResponse.headers.get('etag')).toBe(servedEtag); + expect(await getResponse.text()).toBe(presentedPng); + const reportResponse = await fetch( + `${outputOrigin}/carddav/${seeded.userId}/${pngBook.id}/`, + { + method: 'REPORT', + headers: { + Authorization: authorization, + Depth: '1', + 'Content-Type': 'application/xml', + }, + body: '', + }, + ); + expect(reportResponse.status).toBe(207); + const reportBody = await reportResponse.text(); + expect(reportBody).toContain(servedEtag); + expect(reportBody).toContain(presentedPng + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"')); + await closeServer(outputServer); + + fixture.reset(); + fixture.queueSync('photo-token-2', { + events: [{ href, etag: '"photo-2"' }], + nextToken: 'photo-token-3', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 0 }); + const noOp = await projectionState(seeded.userId); + expect(noOp.books.find(row => row.id === pngBook.id)).toMatchObject({ + sync_token: pngBook.sync_token, + remote_sync_revision: '3', + remote_sync_token: 'photo-token-3', + }); + expect(noOp.contacts).toEqual(pngState.contacts); + + const externalCard = remotePhotoVcard( + 'photo', 'Photo Contact', 'photo@example.test', + 'PHOTO;VALUE=URI:https://images.example.test/private.jpg', + ); + fixture.putContact(href, '"photo-3"', externalCard); + fixture.reset(); + fixture.queueSync('photo-token-3', { + events: [{ href, etag: '"photo-3"' }], + nextToken: 'photo-token-4', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 1 }); + const external = await projectionState(seeded.userId); + expect(external.contacts[0].photo_data).toBeNull(); + expect(external.contacts[0].vcard).not.toContain('PHOTO'); + expect(external.books[0].sync_token).not.toBe(pngBook.sync_token); + await fixture.close(); + }, 120_000); + + it('gates and merges a mapped PUT through the real CardDAV server route', async () => { + const fixture = createCarddavFixtureServer(); + let outputServer; + await fixture.listen(); + try { + const seeded = await seedConnectedUser(fixture); + const href = fixture.href('http-merge.vcf'); + const remoteCard = [ + 'BEGIN:VCARD', 'VERSION:3.0', 'UID:http-merge-remote', 'FN:Http Merge', + 'EMAIL:http-merge@example.test', 'CATEGORIES:Original', 'X-KEEP:survive', 'END:VCARD', + ].join('\n'); + fixture.putContact(href, '"http-1"', remoteCard); + fixture.queueSync('', { events: [{ href, etag: '"http-1"' }], nextToken: 'http-token-1' }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + const { rows: [row] } = await databaseClient.query(` + SELECT c.uid, c.address_book_id FROM contacts c + JOIN carddav_remote_objects o ON o.local_contact_id = c.id + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + `, [seeded.userId]); + + await databaseClient.query('UPDATE users SET password_hash = $2 WHERE id = $1', + [seeded.userId, await bcrypt.hash('http-merge-password', 4)]); + const app = express(); + const { default: carddavRouter } = await import('../routes/carddav.js'); + app.use('/carddav', carddavRouter); + outputServer = await listenOnLocalhost(app); + const origin = `http://127.0.0.1:${outputServer.address().port}`; + const authorization = `Basic ${Buffer.from(`carddav-e2e-${seeded.userId}:http-merge-password`).toString('base64')}`; + const cardUrl = `${origin}/carddav/${seeded.userId}/${row.address_book_id}/${encodeURIComponent(row.uid)}.vcf`; + const put = (headers, body) => fetch(cardUrl, { method: 'PUT', headers: { Authorization: authorization, ...headers }, body }); + + const getResponse = await fetch(cardUrl, { headers: { Authorization: authorization } }); + const servedEtag = getResponse.headers.get('etag'); + + const mergeBody = [ + 'BEGIN:VCARD', 'VERSION:3.0', `UID:${row.uid}`, 'FN:Http Merge', + 'EMAIL:http-merge@example.test', 'CATEGORIES:Changed', 'END:VCARD', '', + ].join('\r\n'); + + // Gate: an unconditional PUT is rejected and nothing reaches upstream. + fixture.reset(); + expect((await put({}, mergeBody)).status).toBe(428); + // Malformed body with a valid If-Match → 400 before any network. + expect((await put({ 'If-Match': servedEtag, 'Content-Type': 'text/vcard' }, 'not a vcard')).status).toBe(400); + expect(fixture.requests.some(request => request.method === 'PUT')).toBe(false); + + // Conditional merge through the gate: 204, and the upstream PUT is the two-tier merge + // with the remote UID preserved. + fixture.putContact(href, '"http-1"', remoteCard); + const merged = await put({ 'If-Match': servedEtag, 'Content-Type': 'text/vcard' }, mergeBody); + expect(merged.status).toBe(204); + const upstream = fixture.requests.find(request => request.method === 'PUT'); + expect(upstream).toBeDefined(); + expect(upstream.body).toContain('CATEGORIES:Changed'); // client's unmodeled edit lands + expect(upstream.body).toContain('X-KEEP:survive'); // omitted unmodeled survives + expect(parseVCard(upstream.body).uid).toBe('http-merge-remote'); // remote UID preserved + } finally { + if (outputServer) await closeServer(outputServer); + await fixture.close(); + } + }, 120_000); + + it('keeps an automatically linked local photo unchanged across remote photo updates', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + const { rows: [localBook] } = await databaseClient.query(` + INSERT INTO address_books (user_id, name) + VALUES ($1, 'Merged Photo Local') RETURNING id, sync_token + `, [seeded.userId]); + const local = { + uid: 'merged-photo-local', + displayName: 'Merged Photo', + firstName: null, + lastName: null, + emails: [{ value: 'merged-photo@example.test', type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + }; + const originalVcard = generateVCard(local); + const originalEtag = createHash('md5').update(originalVcard).digest('hex'); + const { rows: [localRow] } = await databaseClient.query(` + INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, primary_email, + emails, phones, photo_data + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, '[]'::jsonb, NULL) + RETURNING id + `, [ + localBook.id, + seeded.userId, + local.uid, + originalVcard, + originalEtag, + local.displayName, + local.emails[0].value, + JSON.stringify(local.emails), + ]); + const href = fixture.href('merged-photo.vcf'); + const jpeg = remotePhotoVcard( + 'merged-photo-remote', 'Merged Photo', local.emails[0].value, + 'PHOTO;ENCODING=b;TYPE=JPEG:AQID', + ); + fixture.putContact(href, '"merged-photo-1"', jpeg); + fixture.queueSync('', { + events: [{ href, etag: '"merged-photo-1"' }], + nextToken: 'merged-photo-1', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + const jpegState = await projectionState(seeded.userId); + const jpegLocal = jpegState.contacts.find(row => row.id === localRow.id); + const jpegBook = jpegState.books.find(row => row.id === localBook.id); + expect(jpegLocal.photo_data).toBeNull(); + expect(jpegLocal.vcard).toBe(originalVcard); + expect(jpegBook.sync_token).toBe(localBook.sync_token); + + const png = remotePhotoVcard( + 'merged-photo-remote', 'Merged Photo', local.emails[0].value, + 'PHOTO;ENCODING=b;TYPE=PNG:BAUG', + ); + fixture.putContact(href, '"merged-photo-2"', png); + fixture.reset(); + fixture.queueSync('merged-photo-1', { + events: [{ href, etag: '"merged-photo-2"' }], + nextToken: 'merged-photo-2', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + const pngState = await projectionState(seeded.userId); + const pngLocal = pngState.contacts.find(row => row.id === localRow.id); + const pngBook = pngState.books.find(row => row.id === localBook.id); + expect(pngLocal).toEqual(jpegLocal); + expect(pngBook.sync_token).toBe(jpegBook.sync_token); + + fixture.reset(); + fixture.queueSync('merged-photo-2', { + events: [{ href, etag: '"merged-photo-2"' }], + nextToken: 'merged-photo-3', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, updated: 0 }); + const noOp = await projectionState(seeded.userId); + expect(noOp.books.find(row => row.id === localBook.id).sync_token).toBe(pngBook.sync_token); + expect(noOp.contacts.find(row => row.id === localRow.id)).toEqual(pngLocal); + + fixture.deleteContact(href); + fixture.reset(); + fixture.queueSync('merged-photo-3', { + events: [{ href, status: 404 }], + nextToken: 'merged-photo-4', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true, removed: 0 }); + const restored = await projectionState(seeded.userId); + const restoredLocal = restored.contacts.find(row => row.id === localRow.id); + expect(restoredLocal).toMatchObject({ + id: localRow.id, + display_name: local.displayName, + primary_email: local.emails[0].value, + emails: local.emails, + photo_data: null, + }); + expect(parseVCard(restoredLocal.vcard)).toMatchObject({ + uid: local.uid, + displayName: local.displayName, + emails: local.emails, + photoData: null, + }); + expect(restored.books.find(row => row.id === localBook.id).sync_token) + .not.toBe(pngBook.sync_token); + expect(restored.ledger).toEqual([expect.objectContaining({ + href: fixture.href(`${local.uid}.vcf`), + vcard: restoredLocal.vcard, + primary_email: local.emails[0].value, + local_contact_id: localRow.id, + })]); + expect(fixture.counters.create).toBe(1); + await fixture.close(); + }, 120_000); + + it('replacement queues exactly one latest generation after old planning releases', async () => { + const oldFixture = createCarddavFixtureServer(); + const newFixture = createCarddavFixtureServer(); + await oldFixture.listen(); + await newFixture.listen(); + const seeded = await seedConnectedUser(oldFixture); + const oldContact = await seedSingleRemoteContact(oldFixture, seeded.userId, { + uid: 'old-generation', + name: 'Old Generation', + token: 'old-generation-token', + }); + const oldState = await projectionState(seeded.userId); + const oldBook = oldState.books.find(row => row.source === 'carddav'); + const oldBarrier = deferred(); + const oldReached = deferred(); + oldFixture.reset(); + oldFixture.queueSync(oldContact.token, { + events: [], + nextToken: 'old-generation-must-not-commit', + waitFor: oldBarrier.promise, + reached: oldReached.resolve, + }); + + const newHref = newFixture.href('new-generation.vcf'); + const newCard = remoteVcard('new-generation', 'New Generation'); + newFixture.putContact(newHref, '"new-generation-1"', newCard); + newFixture.queueSync('', { + events: [{ href: newHref, etag: '"new-generation-1"' }], + nextToken: 'new-generation-token', + }); + const oldPending = carddavSync.syncUser(seeded.userId); + await oldReached.promise; + + const app = express(); + app.use(express.json()); + app.use((request, response, next) => { + request.session = { userId: seeded.userId }; + next(); + }); + const { default: carddavAccountRouter } = await import('../routes/carddavAccount.js'); + app.use('/carddav-account', carddavAccountRouter); + const accountServer = await listenOnLocalhost(app); + const accountOrigin = `http://127.0.0.1:${accountServer.address().port}`; + const replacementResponse = await fetch(`${accountOrigin}/carddav-account/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverUrl: newFixture.serverUrl, + username: 'replacement-user', + password: 'replacement-password', + intervalMin: 60, + }), + }); + expect(replacementResponse.status).toBe(200); + expect(await replacementResponse.json()).toEqual({ + connected: true, + serverUrl: newFixture.serverUrl, + username: 'replacement-user', + intervalMin: 60, + lastSyncAt: null, + lastError: null, + bookCount: null, + contactCount: 1, + }); + const [replacement] = await integrationState(seeded.userId); + expect(replacement.config).toEqual({ + serverUrl: newFixture.serverUrl, + username: 'replacement-user', + password: expect.stringMatching(/^enc:v1:/), + intervalMin: 60, + connectionGeneration: expect.any(String), + lastError: null, + contactCount: 1, + }); + expect(replacement.config.connectionGeneration).not.toBe(seeded.connectionGeneration); + expect(replacement.config.password).not.toBe('replacement-password'); + oldBarrier.resolve(); + + expect(await oldPending).toEqual({ + ok: false, + error: 'CardDAV sync plan is stale', + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, + }); + await waitForPostgresState({ + description: 'queued replacement generation to finish', + probe: async () => { + const [integration] = await integrationState(seeded.userId); + const state = { + connectionGeneration: integration?.config?.connectionGeneration ?? null, + hasLastError: integration?.config?.lastError != null, + bookCount: integration?.config?.bookCount ?? null, + contactCount: integration?.config?.contactCount ?? null, + lastSyncAt: integration?.config?.lastSyncAt ?? null, + }; + return { + done: state.connectionGeneration === replacement.config.connectionGeneration + && !state.hasLastError + && state.bookCount === 1 + && state.contactCount === 1 + && Boolean(state.lastSyncAt), + state, + }; + }, + }); + + const finalProjection = await projectionState(seeded.userId); + expect(finalProjection.books).toHaveLength(1); + expect(finalProjection.books[0]).toMatchObject({ + source: 'carddav', + external_url: newFixture.href(''), + remote_sync_token: 'new-generation-token', + remote_sync_capability: 'sync-collection', + remote_sync_revision: '1', + remote_projection_fingerprint: expect.any(String), + }); + expect(finalProjection.books[0].id).not.toBe(oldBook.id); + const finalContact = finalProjection.contacts[0]; + expect(finalProjection.contacts).toEqual([ + expectedLocalContact( + newHref, + newCard, + finalProjection.books[0].id, + seeded.userId, + finalContact.id, + ), + ]); + expect(finalProjection.ledger).toEqual([{ + address_book_id: finalProjection.books[0].id, + href: newHref, + remote_etag: '"new-generation-1"', + vcard: newCard, + primary_email: 'new-generation@example.test', + local_contact_id: finalContact.id, + }]); + const [finalIntegration] = await integrationState(seeded.userId); + expect(finalIntegration.config).toEqual({ + ...replacement.config, + lastSyncAt: expect.any(String), + lastError: null, + bookCount: 1, + contactCount: 1, + exportFailures: [], + }); + expect(Number.isNaN(Date.parse(finalIntegration.config.lastSyncAt))).toBe(false); + expect(oldFixture.counters).toMatchObject({ + requests: 4, + propfind: 3, + sync: 1, + multiget: 0, + syncTokens: ['old-generation-token'], + }); + expect(newFixture.counters).toMatchObject({ + requests: 8, + propfind: 6, + sync: 1, + multiget: 1, + syncTokens: [''], + multigetSizes: [1], + }); + const newAuthorization = `Basic ${Buffer.from( + 'replacement-user:replacement-password', + ).toString('base64')}`; + expect(newFixture.requests.every(request => request.authorization === newAuthorization)) + .toBe(true); + expect(oldFixture.requests.every(request => ( + request.authorization === 'Basic Zml4dHVyZS11c2VyOmZpeHR1cmUtcGFzc3dvcmQ=' + ))).toBe(true); + + carddavSync.stopCardavUser(seeded.userId); + await closeServer(accountServer); + await oldFixture.close(); + await newFixture.close(); + }, 120_000); + + it('changed username on the same collection invalidates the old token and fully reconciles', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture, { + username: 'identity-old-user', + password: 'identity-old-password', + }); + const retainedHref = fixture.href('identity-retained.vcf'); + const oldOnlyHref = fixture.href('identity-old-only.vcf'); + const retainedBefore = remoteVcard('identity-retained', 'Identity Retained Before'); + const oldOnly = remoteVcard('identity-old-only', 'Identity Old Only'); + fixture.putContact(retainedHref, '"identity-retained-1"', retainedBefore); + fixture.putContact(oldOnlyHref, '"identity-old-only-1"', oldOnly); + fixture.queueSync('', { + events: [ + { href: retainedHref, etag: '"identity-retained-1"' }, + { href: oldOnlyHref, etag: '"identity-old-only-1"' }, + ], + nextToken: 'identity-old-token', + }); + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, contactCount: 2, + }); + const beforeReplacement = await projectionState(seeded.userId); + const beforeBook = beforeReplacement.books.find(book => book.source === 'carddav'); + + fixture.reset(); + const retainedAfter = remoteVcard('identity-retained', 'Identity Retained After'); + fixture.putContact(retainedHref, '"identity-retained-2"', retainedAfter); + fixture.deleteContact(oldOnlyHref); + fixture.queueSync('identity-old-token', { + events: [], + nextToken: 'identity-wrong-partial-token', + }); + fixture.queueSync('', { + events: [{ href: retainedHref, etag: '"identity-retained-2"' }], + nextToken: 'identity-new-token', + }); + const replacement = await carddavSync.replaceCarddavConnection(seeded.userId, { + serverUrl: fixture.serverUrl, + username: 'identity-new-user', + password: encrypt('identity-new-password'), + intervalMin: 60, + }); + expect(replacement.connectionGeneration).not.toBe(seeded.connectionGeneration); + const invalidated = await projectionState(seeded.userId); + expect(invalidated.books.find(book => book.id === beforeBook.id)).toMatchObject({ + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: String(Number(beforeBook.remote_sync_revision) + 1), + remote_projection_fingerprint: null, + }); + expect(invalidated.contacts).toEqual(beforeReplacement.contacts); + expect(invalidated.ledger).toEqual(beforeReplacement.ledger); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, contactCount: 1, removed: 1, + }); + const after = await projectionState(seeded.userId); + expect(after.books).toHaveLength(1); + expect(after.books[0]).toMatchObject({ + id: beforeBook.id, + remote_sync_token: 'identity-new-token', + remote_sync_capability: 'sync-collection', + }); + expect(after.contacts).toHaveLength(1); + expect(after.contacts[0].display_name).toBe('Identity Retained After'); + expect(after.ledger.map(object => object.href)).toEqual([retainedHref]); + expect(fixture.counters.syncTokens).toEqual(['']); + const expectedAuthorization = `Basic ${Buffer.from( + 'identity-new-user:identity-new-password', + ).toString('base64')}`; + expect(fixture.requests.filter(request => request.authorization) + .every(request => request.authorization === expectedAuthorization)).toBe(true); + await fixture.close(); + }, 120_000); + + async function materializedCarddavContactCount(userId) { + const { rows: [row] } = await databaseClient.query(` + SELECT count(*)::int AS count + FROM contacts c + JOIN address_books b ON b.id = c.address_book_id + WHERE c.user_id = $1 AND b.source = 'carddav' + `, [userId]); + return row.count; + } + + // Puts `size` contacts in the remote book and scripts the snapshot that reports all of + // them for `syncToken` — re-callable, so a full re-fetch can be replayed after a reset. + function queueRemoteSnapshot(fixture, { size, syncToken = '', nextToken }) { + const events = []; + for (let index = 0; index < size; index++) { + const uid = `count-${index}`; + const href = fixture.href(`${uid}.vcf`); + const etag = `"etag-${uid}"`; + fixture.putContact(href, etag, remoteVcard(uid, `Count Contact ${index}`)); + events.push({ href, etag }); + } + fixture.queueSync(syncToken, { events, nextToken }); + } + + async function clearRemoteSyncTokens(userId) { + await databaseClient.query(` + UPDATE address_books + SET remote_sync_token = NULL, remote_projection_fingerprint = NULL + WHERE user_id = $1 AND source = 'carddav' + `, [userId]); + } + + // contactCount was accumulated (a per-book delta added to the stored total), so a + // full snapshot against an empty ledger added the book's contacts on top of a total that + // already counted them — exactly what an upgrade from the read-only sync leaves behind. + it('includes contacts published by the export sweep in the finalized contactCount', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + await seedUnmappedExplicitContact(seeded.userId, { + uid: 'export-count', + displayName: 'Export Count', + }); + fixture.queueSync('', { events: [], nextToken: 'export-count-token' }); + + const result = await carddavSync.syncUser(seeded.userId); + const state = await projectionState(seeded.userId); + const [integration] = await integrationState(seeded.userId); + + expect(result).toMatchObject({ ok: true, contactCount: 1, exportFailures: [] }); + expect(state.ledger).toHaveLength(1); + expect(integration.config.contactCount).toBe(1); + await fixture.close(); + }, 120_000); + + it('recounts contactCount on a full snapshot inheriting a read-only total', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + queueRemoteSnapshot(fixture, { size: 3, nextToken: 'count-token-1' }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(3); + + // The state an upgrade from the read-only sync lands in: contacts are materialized and + // config carries their (correct) count, but the mapping ledger is new and empty, and + // the cleared remote token forces the next sync down the full-snapshot path. + await databaseClient.query(` + DELETE FROM carddav_remote_objects o + USING address_books b + WHERE b.id = o.address_book_id AND b.user_id = $1 + `, [seeded.userId]); + await clearRemoteSyncTokens(seeded.userId); + fixture.reset(); + queueRemoteSnapshot(fixture, { size: 3, nextToken: 'count-token-2' }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + expect(await materializedCarddavContactCount(seeded.userId)).toBe(3); + expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(3); + await fixture.close(); + }, 120_000); + + it('heals an already inflated contactCount on the next sync', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + queueRemoteSnapshot(fixture, { size: 2, nextToken: 'inflated-token-1' }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + await databaseClient.query(` + UPDATE user_integrations + SET config = jsonb_set(config, '{contactCount}', to_jsonb(1772)) + WHERE user_id = $1 AND provider = 'carddav' + `, [seeded.userId]); + fixture.reset(); + fixture.queueSync('inflated-token-1', { events: [], nextToken: 'inflated-token-2' }); + + // An idempotent sync: nothing changed remotely, so only a derived count converges. + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ + ok: true, + contactCount: 2, + }); + + expect(await materializedCarddavContactCount(seeded.userId)).toBe(2); + expect((await integrationState(seeded.userId))[0].config.contactCount).toBe(2); + await fixture.close(); + }, 120_000); + + it('keeps contactCount equal to the row count across repeated full re-fetches', async () => { + const fixture = createCarddavFixtureServer(); + await fixture.listen(); + const seeded = await seedConnectedUser(fixture); + queueRemoteSnapshot(fixture, { size: 4, nextToken: 'refetch-token-0' }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + + for (let pass = 1; pass <= 2; pass++) { + await clearRemoteSyncTokens(seeded.userId); + fixture.reset(); + queueRemoteSnapshot(fixture, { size: 4, nextToken: `refetch-token-${pass}` }); + + expect(await carddavSync.syncUser(seeded.userId)).toMatchObject({ ok: true }); + expect((await integrationState(seeded.userId))[0].config.contactCount) + .toBe(await materializedCarddavContactCount(seeded.userId)); + } + + expect(await materializedCarddavContactCount(seeded.userId)).toBe(4); + await fixture.close(); + }, 120_000); +}); diff --git a/backend/src/services/carddavSync.js b/backend/src/services/carddavSync.js index ddbe589c..1b8a0645 100644 --- a/backend/src/services/carddavSync.js +++ b/backend/src/services/carddavSync.js @@ -1,18 +1,120 @@ // CardDAV sync orchestration + scheduler. Pulls contacts from a user's connected // CardDAV server (provider='carddav' in user_integrations) into per-remote-book, -// read-only local address books. One-way / read-only. Duplicate handling across -// books is chosen by the user: 'separate' | 'merge' | 'skip'. +// read-only local address books. Remote objects are linked to explicit local +// contacts deterministically and unmatched contacts are imported or exported. import crypto from 'crypto'; -import { query } from './db.js'; -import { decrypt } from './encryption.js'; -import { parseVCard } from '../utils/vcard.js'; -import { getConnectionPolicy } from './connectionPolicy.js'; -import { discoverAddressBooks, fetchAddressBookCards } from './carddavClient.js'; +import { + localContactHash, + localVCardEtag, + overlayContactOnVCard, + parseVCardDocument, + presentedVCard, + pushSafeSnapshot, + primaryEmail, + semanticVCardHash, + serializeVCardDocument, +} from '../utils/vcardProperties.js'; +import { query, withTransaction } from './db.js'; +import { generateVCard, parseVCard } from '../utils/vcard.js'; +import { discoverAddressBooks, fetchAddressBookDelta } from './carddavClient.js'; +import { + CardDavError, + activeRetryAfterAt, + resolveCarddavCredentials, +} from './carddavTransport.js'; +import { + exportExistingContact, + recoverPendingCarddavMutations, +} from './carddavContactService.js'; +import { deleteResolvedConflictsBefore } from './carddavConflictService.js'; +import { + advanceDiscoveredBookState, + applyConfirmedRemoteContact, + applyRemoteTombstone, + lockCarddavIntegration, + normalizeCarddavCapabilities, + persistDiscoveredBook, + refreshUnresolvedConflict, +} from './carddavMappingState.js'; +import { normalizeEmail, planAutomaticProjection } from './carddavProjection.js'; const DEFAULT_INTERVAL_MIN = 60; +const CONFLICT_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; +const CONFLICT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const timers = new Map(); // userId -> interval id +let conflictCleanupTimer; const syncing = new Set(); // userIds with a sync in flight (prevents overlap) +const activeSyncGenerations = new Map(); +const pendingReplacementSyncs = new Set(); +const RESULT_COUNTERS = [ + 'remote', 'fetched', 'updated', 'removed', 'fallback', +]; + +const emptyResultCounters = () => Object.fromEntries(RESULT_COUNTERS.map(counter => [counter, 0])); +const PLAN_FENCES = ['connectionGeneration', 'expectedRemoteRevision', 'expectedRemoteToken']; +const STALE_PLAN_ACTIONS = Object.freeze({ + 'invalid-plan-fence': 'abort', + 'not-connected': 'abort', + 'connection-generation-changed': 'abort', + 'projection-footprint-changed': 'retry-apply', + 'mapping-revision-changed': 'abort', + 'mapping-contact-missing': 'abort', + 'canonical-url-conflict': 'abort', + 'book-update-missed': 'abort', + 'canonical-reconciliation-required': 'abort', + 'observed-alias-missing': 'abort', + 'remote-revision-changed': 'refetch-once', + 'remote-token-changed': 'refetch-once', +}); + +function addResultCounters(total, counters) { + for (const counter of RESULT_COUNTERS) total[counter] += counters[counter] || 0; +} + +function carddavContactCount(config) { + const count = Number(config?.contactCount); + return Number.isSafeInteger(count) && count >= 0 ? count : 0; +} + +function projectionFingerprint(books) { + const inputs = books + .map(book => [book.id, book.sync_token]) + .sort(([left], [right]) => left.localeCompare(right)); + return crypto.createHash('sha256').update(JSON.stringify(inputs)).digest('hex'); +} + +function requirePlanFences(plan) { + if (PLAN_FENCES.every(field => Object.hasOwn(plan, field))) return; + throw new StaleCarddavPlanError({ reason: 'invalid-plan-fence' }); +} + +export function stalePlanAction(reason) { + if (Object.hasOwn(STALE_PLAN_ACTIONS, reason)) return STALE_PLAN_ACTIONS[reason]; + throw new TypeError(`Unknown stale CardDAV plan reason: ${reason}`); +} + +function observedCapabilities(book) { + return normalizeCarddavCapabilities(book.capabilities); +} + +export class StaleCarddavPlanError extends Error { + constructor(details) { + stalePlanAction(details?.reason); + super(details?.reason === 'not-connected' ? 'not connected' : 'CardDAV sync plan is stale'); + this.name = 'StaleCarddavPlanError'; + Object.assign(this, details); + } +} + +export function assertConnectionGeneration(actual, expected) { + if (expected === undefined || actual === expected) return; + throw new StaleCarddavPlanError({ + reason: 'connection-generation-changed', + expectedConnectionGeneration: expected, + actualConnectionGeneration: actual ?? null, + }); +} export async function getCardavConfig(userId) { const r = await query( @@ -22,179 +124,1232 @@ export async function getCardavConfig(userId) { return r.rows[0]?.config || null; } -// Shallow-merge a patch into the stored JSONB config. -export async function saveCardavConfig(userId, patch) { - await query( - `UPDATE user_integrations SET config = config || $2::jsonb, updated_at = NOW() - WHERE user_id = $1 AND provider = 'carddav'`, - [userId, JSON.stringify(patch)], +async function invalidateCarddavBookIdentity(client, userId) { + const { rows: books } = await client.query( + `SELECT id + FROM address_books + WHERE user_id = $1 AND source = 'carddav' + ORDER BY id + FOR UPDATE`, + [userId], ); -} - -// Find or create the local read-only address book mirroring a remote collection, -// keyed by external_url. Address-book names are unique per user, so on a name -// clash we disambiguate with a suffix. -async function ensureCardavBook(userId, book) { - const existing = await query( - "SELECT id FROM address_books WHERE user_id = $1 AND external_url = $2", - [userId, book.url], + if (!books.length) return; + await client.query( + `UPDATE address_books + SET remote_sync_token = NULL, + remote_sync_capability = 'unknown', + remote_sync_revision = remote_sync_revision + 1, + remote_projection_fingerprint = NULL, + updated_at = NOW() + WHERE user_id = $1 AND id = ANY($2::uuid[])`, + [userId, books.map(book => book.id)], ); - if (existing.rows.length) return existing.rows[0].id; +} - for (let attempt = 0; attempt < 20; attempt++) { - const name = attempt === 0 ? book.displayName : `${book.displayName} (${attempt + 1})`; - try { - const r = await query( - `INSERT INTO address_books (user_id, name, source, external_url) - VALUES ($1, $2, 'carddav', $3) RETURNING id`, - [userId, name, book.url], +export async function replaceCarddavConnection(userId, connection) { + return withTransaction(async client => { + const integration = await lockCarddavIntegration(client, userId); + const contactCount = integration ? carddavContactCount(integration.config) : null; + if (integration) { + const identityChanged = integration.config?.serverUrl !== connection.serverUrl + || integration.config?.username !== connection.username; + if (identityChanged) await invalidateCarddavBookIdentity(client, userId); + } + const config = { + serverUrl: connection.serverUrl, + username: connection.username, + password: connection.password, + intervalMin: connection.intervalMin ?? DEFAULT_INTERVAL_MIN, + connectionGeneration: crypto.randomUUID(), + lastError: null, + }; + if (contactCount !== null) config.contactCount = contactCount; + if (integration) { + const updated = await client.query( + `UPDATE user_integrations + SET config = $2::jsonb, updated_at = NOW() + WHERE id = $1`, + [integration.id, JSON.stringify(config)], + ); + if (updated.rowCount !== 1) throw new StaleCarddavPlanError({ reason: 'not-connected' }); + } else { + await client.query( + `INSERT INTO user_integrations (user_id, provider, config) + VALUES ($1, 'carddav', $2::jsonb)`, + [userId, JSON.stringify(config)], ); - return r.rows[0].id; - } catch (err) { - if (err.code === '23505') continue; // name taken — try next suffix - throw err; } + return config; + }); +} + +export async function patchCarddavConnection(userId, patch, expectedGeneration) { + return withTransaction(async client => { + const integration = await lockCarddavIntegration(client, userId); + if (!integration) throw new StaleCarddavPlanError({ reason: 'not-connected' }); + const actualGeneration = integration.config?.connectionGeneration ?? null; + assertConnectionGeneration(actualGeneration, expectedGeneration); + const identityChanged = ['serverUrl', 'username'].some(field => ( + Object.hasOwn(patch, field) && patch[field] !== integration.config?.[field] + )); + if (identityChanged) await invalidateCarddavBookIdentity(client, userId); + + const nextPatch = {}; + for (const field of ['serverUrl', 'username', 'password', 'intervalMin']) { + if (Object.hasOwn(patch, field)) nextPatch[field] = patch[field]; + } + if (['serverUrl', 'username', 'password'].some(field => Object.hasOwn(patch, field))) { + nextPatch.connectionGeneration = crypto.randomUUID(); + nextPatch.lastError = null; + } + const config = { ...integration.config, ...nextPatch }; + const updated = await client.query( + `UPDATE user_integrations + SET config = $2::jsonb, updated_at = NOW() + WHERE id = $1`, + [integration.id, JSON.stringify(config)], + ); + if (updated.rowCount !== 1) throw new StaleCarddavPlanError({ reason: 'not-connected' }); + return config; + }); +} + +async function lockCarddavBooks(client, userId, urls) { + const result = await client.query( + `SELECT id, external_url, remote_sync_token, remote_sync_revision::text, sync_token, + remote_projection_fingerprint + FROM address_books + WHERE user_id = $1 AND source = 'carddav' AND external_url = ANY($2::text[]) + ORDER BY id + FOR UPDATE`, + [userId, urls], + ); + return result.rows; +} + +async function lockEligibleTargetBooks(client, userId) { + const result = await client.query( + `SELECT id, source, sync_token + FROM address_books + WHERE user_id = $1 AND source <> 'carddav' + ORDER BY id + FOR UPDATE`, + [userId], + ); + return result.rows; +} + +async function validateTargetBookFootprint(client, userId, lockedBooks) { + const { rows: currentBooks } = await client.query( + `SELECT id, source, sync_token + FROM address_books + WHERE user_id = $1 AND source <> 'carddav' + ORDER BY id`, + [userId], + ); + const unchanged = lockedBooks.length === currentBooks.length + && lockedBooks.every((book, index) => ( + book.id === currentBooks[index].id + && book.sync_token === currentBooks[index].sync_token + )); + if (!unchanged) { + throw new StaleCarddavPlanError({ reason: 'projection-footprint-changed' }); + } +} + +async function lockProjectionContacts(client, userId, sourceBookId) { + const result = await client.query(` + SELECT c.id, c.address_book_id, ab.source AS address_book_source, + c.uid, c.vcard, c.etag, c.display_name, c.first_name, c.last_name, + c.primary_email, c.emails, c.phones, c.organization, c.notes, c.photo_data, + c.additional_fields, c.is_auto + FROM contacts c + JOIN address_books ab ON ab.id = c.address_book_id + WHERE c.user_id = $1 + AND (c.address_book_id = $2 OR ab.source <> 'carddav') + ORDER BY c.id + FOR UPDATE OF c + `, [userId, sourceBookId]); + return result.rows; +} + +async function validateProjectionFootprint(client, userId, targetBooks, contactRows, sourceBookId) { + const currentBooks = await client.query( + `SELECT id + FROM address_books + WHERE user_id = $1 AND source <> 'carddav' + ORDER BY id`, + [userId], + ); + const currentContacts = await client.query( + `SELECT c.id + FROM contacts c + JOIN address_books ab ON ab.id = c.address_book_id + WHERE c.user_id = $1 + AND (c.address_book_id = $2 OR ab.source <> 'carddav') + ORDER BY c.id`, + [userId, sourceBookId], + ); + const sameIds = (left, right) => left.length === right.length + && left.every((row, index) => row.id === right[index].id); + if (sameIds(targetBooks, currentBooks.rows) && sameIds(contactRows, currentContacts.rows)) return; + throw new StaleCarddavPlanError({ reason: 'projection-footprint-changed' }); +} + +async function rotateChangedBookTokens(client, userId, changedBookIds) { + const ids = [...new Set(changedBookIds)].sort(); + if (!ids.length) return []; + const result = await client.query( + `UPDATE address_books + SET sync_token = gen_random_uuid()::text, updated_at = NOW() + WHERE user_id = $1 AND id = ANY($2::uuid[]) + RETURNING id, source, sync_token`, + [userId, ids], + ); + if (result.rowCount !== ids.length) { + throw new StaleCarddavPlanError({ reason: 'projection-footprint-changed' }); } - throw new Error(`Could not create a local address book for "${book.displayName}"`); + return result.rows; } function contactFromVCard(vcard, href) { const c = parseVCard(vcard); const uid = c.uid || crypto.createHash('md5').update(href).digest('hex'); - const primaryEmail = c.emails.find(e => e.primary)?.value || c.emails[0]?.value || null; + const preferredEmail = primaryEmail(c); return { uid, - displayName: c.displayName || primaryEmail || null, + displayName: c.displayName || preferredEmail || null, firstName: c.firstName, lastName: c.lastName, - primaryEmail: primaryEmail ? primaryEmail.toLowerCase().trim() : null, + primaryEmail: preferredEmail, emails: c.emails, phones: c.phones, organization: c.organization, notes: c.notes, photoData: c.photoData, + additionalFields: c.additionalFields || [], vcard, }; } -async function upsertCardavContact(bookId, userId, c) { - const etag = crypto.createHash('md5').update(c.vcard).digest('hex'); - await query(` - INSERT INTO contacts ( - address_book_id, user_id, uid, vcard, etag, - display_name, first_name, last_name, primary_email, - emails, phones, organization, notes, photo_data, is_auto - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,false) - ON CONFLICT (address_book_id, uid) DO UPDATE SET - vcard = EXCLUDED.vcard, etag = EXCLUDED.etag, - display_name = EXCLUDED.display_name, first_name = EXCLUDED.first_name, - last_name = EXCLUDED.last_name, primary_email = EXCLUDED.primary_email, - emails = EXCLUDED.emails, phones = EXCLUDED.phones, - organization = EXCLUDED.organization, notes = EXCLUDED.notes, - photo_data = EXCLUDED.photo_data, updated_at = NOW() - `, [ - bookId, userId, c.uid, c.vcard, etag, - c.displayName, c.firstName, c.lastName, c.primaryEmail, - JSON.stringify(c.emails), JSON.stringify(c.phones), - c.organization, c.notes, c.photoData, - ]); -} - -// Enrich an existing contact (in another book) with the vCard's descriptive -// fields. We deliberately leave primary_email/emails untouched to avoid churning -// that book's per-book email-uniqueness index. -async function mergeIntoExisting(id, c) { - const etag = crypto.createHash('md5').update(c.vcard).digest('hex'); - await query(` - UPDATE contacts SET - display_name = $2, first_name = $3, last_name = $4, - phones = $5::jsonb, organization = $6, notes = $7, - photo_data = COALESCE($8, photo_data), vcard = $9, etag = $10, updated_at = NOW() - WHERE id = $1 - `, [id, c.displayName, c.firstName, c.lastName, JSON.stringify(c.phones), - c.organization, c.notes, c.photoData, c.vcard, etag]); -} - -async function syncBook(userId, book, dupMode, creds) { - const bookId = await ensureCardavBook(userId, book); - const rawCards = await fetchAddressBookCards({ ...book, ...creds }); - const cards = rawCards.map(rc => contactFromVCard(rc.vcard, rc.href)); - - // Emails present in the user's OTHER books, for cross-book duplicate handling. - const otherEmail = new Map(); // email -> existing contact id - if (dupMode !== 'separate') { - const rows = await query( - `SELECT id, primary_email FROM contacts - WHERE user_id = $1 AND address_book_id <> $2 AND primary_email IS NOT NULL`, - [userId, bookId], +function contactFromRow(row, bookId) { + return { + id: row.id, + addressBookId: row.address_book_id, + inRemoteBook: row.address_book_id === bookId, + isCarddavProjected: row.address_book_source === 'carddav', + uid: row.uid, + vcard: row.vcard, + etag: row.etag, + displayName: row.display_name, + firstName: row.first_name, + lastName: row.last_name, + primaryEmail: row.primary_email, + emails: row.emails, + phones: row.phones, + organization: row.organization, + notes: row.notes, + photoData: row.photo_data, + additionalFields: row.additional_fields || [], + isAuto: row.is_auto, + }; +} + +// A linked local contact tracks inbound remote changes (edits and deletes) when it +// lives in the CardDAV book (materialized from the remote) or shares the remote +// resource's UID (MailFlow pushed the remote from this contact). A contact merged to +// a remote only by matching email keeps a distinct UID and stays locally +// authoritative, so remote edits never overwrite it and a remote delete only unlinks. +function linkedContactFollowsRemote(contact, bookId, retainedVcard) { + if (contact.addressBookId === bookId) return true; + if (!retainedVcard) return false; + const retainedUid = parseVCard(retainedVcard).uid; + return Boolean(retainedUid) && retainedUid === contact.uid; +} + +// The local snapshot stored on a sync-created conflict is pushed verbatim by a later +// keep-mailflow resolution, so it must be lossless AND carry the retained remote UID. +// Overlay the current local contact onto the retained remote vCard (the same overlay +// updateContact/replace use). If the overlay throws, re-key the stored vCard to the remote +// UID or fail closed (pushSafeSnapshot) — the fallback must never mint the local key +// onto the remote resource. The !mapping.vcard guard is defensive: a real +// mapped contact always has a retained vCard; when it is absent the contact is push-origin, +// whose local UID already equals the remote UID, so its stored vCard is safe. +function conflictLocalSnapshot(mapping, contact) { + if (!contact) return null; + if (!mapping.vcard) return contact.vcard; + const retained = parseVCardDocument(mapping.vcard); + try { + return serializeVCardDocument( + overlayContactOnVCard(retained, contact, { preserveDocumentUid: true }), ); - for (const r of rows.rows) otherEmail.set(r.primary_email.toLowerCase(), r.id); + } catch (error) { + return pushSafeSnapshot(contact.vcard, retained, error); + } +} + +function automaticMappingFromRow(row) { + const document = row.vcard && (!row.vcard_version || !row.remote_semantic_hash) + ? parseVCardDocument(row.vcard) + : null; + return { + addressBookId: row.address_book_id, + href: row.href, + remoteEtag: row.remote_etag, + vcard: row.vcard, + primaryEmail: row.primary_email, + localContactId: row.local_contact_id, + mappingStatus: row.mapping_status ?? 'synced', + vcardVersion: row.vcard_version ?? document?.version ?? null, + remoteSemanticHash: row.remote_semantic_hash + ?? (document ? semanticVCardHash(document) : null), + localContactHash: row.local_contact_hash, + mappingRevision: row.mapping_revision ?? '0', + legacyProjection: row.legacy_projection, + }; +} + +function desiredAutomaticContact(remote, primaryEmail, uid) { + const contact = { + ...remote.contact, + // Imports key the local UID to the remote href; refreshes preserve the existing + // UID so a push-origin contact keeps its identity across an inbound remote edit. + uid: uid ?? crypto.createHash('sha256').update(remote.href).digest('hex'), + primaryEmail, + additionalFields: remote.contact?.additionalFields || [], + }; + const vcard = generateVCard(contact); + return { ...contact, vcard, etag: localVCardEtag(vcard), isAuto: false }; +} + +function confirmedAutomaticMapping(remote, contactId, contact) { + const document = parseVCardDocument(remote.vcard); + return { + href: remote.href, + remoteEtag: remote.remoteEtag ?? null, + vcard: remote.vcard, + primaryEmail: remote.contact?.primaryEmail ?? remote.primaryEmail ?? null, + localContactId: contactId, + vcardVersion: document.version, + remoteSemanticHash: semanticVCardHash(document), + localContactHash: localContactHash(contact), + }; +} + +function assertMappingStateApplied(result, mapping) { + if (result.ok) return result; + throw new StaleCarddavPlanError({ + reason: 'mapping-revision-changed', + addressBookId: mapping.addressBookId, + href: mapping.href, + expectedMappingRevision: mapping.mappingRevision, + }); +} + +async function loadAutomaticProjectionState(client, userId, bookId, hasLegacyProjection) { + const legacyProjection = hasLegacyProjection + ? 'o.legacy_projection' + : 'NULL::jsonb AS legacy_projection'; + const { rows: mappingRows } = await client.query( + `SELECT o.address_book_id, o.href, o.remote_etag, o.vcard, o.primary_email, + o.local_contact_id, o.mapping_status, o.vcard_version, + o.remote_semantic_hash, o.local_contact_hash, + o.mapping_revision::text, ${legacyProjection} + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + ORDER BY b.id, o.href + FOR UPDATE OF o`, + [userId], + ); + const contactRows = await lockProjectionContacts(client, userId, bookId); + return { + mappings: mappingRows.map(automaticMappingFromRow), + contacts: contactRows.map(row => contactFromRow(row, bookId)), + }; +} + +async function materializeAutomaticImport(client, userId, book, remote, primaryEmail) { + const desired = desiredAutomaticContact(remote, primaryEmail); + const result = await client.query( + `INSERT INTO contacts ( + address_book_id, user_id, uid, vcard, etag, display_name, first_name, + last_name, primary_email, emails, phones, organization, notes, photo_data, + additional_fields, is_auto + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12,$13,$14,$15::jsonb,false + ) + ON CONFLICT (address_book_id, uid) DO UPDATE SET + vcard = EXCLUDED.vcard, etag = EXCLUDED.etag, + display_name = EXCLUDED.display_name, first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, primary_email = EXCLUDED.primary_email, + emails = EXCLUDED.emails, phones = EXCLUDED.phones, + organization = EXCLUDED.organization, notes = EXCLUDED.notes, + photo_data = EXCLUDED.photo_data, additional_fields = EXCLUDED.additional_fields, + is_auto = false, updated_at = NOW() + RETURNING id`, + [ + book.id, userId, desired.uid, desired.vcard, desired.etag, + desired.displayName, desired.firstName, desired.lastName, desired.primaryEmail, + JSON.stringify(desired.emails || []), JSON.stringify(desired.phones || []), + desired.organization, desired.notes, desired.photoData, + JSON.stringify(desired.additionalFields || []), + ], + ); + return { + id: result.rows[0].id, + addressBookId: book.id, + inRemoteBook: true, + isCarddavProjected: true, + ...desired, + }; +} + +async function refreshAutomaticImport(client, userId, book, remote, current, primaryEmail) { + // Refresh the linked contact in place — a push-origin contact keeps its own local + // book and UID, a pull-origin contact keeps the book/UID it was materialized with. + const desired = desiredAutomaticContact(remote, primaryEmail, current.uid); + if (localContactHash(desired) === localContactHash(current)) { + return { contact: current, changed: false }; } + const result = await client.query( + `UPDATE contacts SET + uid = $3, vcard = $4, etag = $5, display_name = $6, + first_name = $7, last_name = $8, primary_email = $9, + emails = $10::jsonb, phones = $11::jsonb, organization = $12, + notes = $13, photo_data = $14, additional_fields = $15::jsonb, + is_auto = false, updated_at = NOW() + WHERE user_id = $1 AND id = $2 AND address_book_id = $16`, + [ + userId, current.id, desired.uid, desired.vcard, desired.etag, + desired.displayName, desired.firstName, desired.lastName, desired.primaryEmail, + JSON.stringify(desired.emails || []), JSON.stringify(desired.phones || []), + desired.organization, desired.notes, desired.photoData, + JSON.stringify(desired.additionalFields || []), current.addressBookId, + ], + ); + if (result.rowCount !== 1) { + throw new StaleCarddavPlanError({ reason: 'mapping-contact-missing' }); + } + return { + contact: { + ...current, + ...desired, + addressBookId: current.addressBookId, + inRemoteBook: current.addressBookId === book.id, + isCarddavProjected: current.isCarddavProjected, + }, + changed: true, + }; +} - // Classify first (no writes) so we know the final set before touching the DB. - const seenInBook = new Set(); - const toUpsert = []; - const toMerge = []; // { id, contact } - for (const c of cards) { - // Avoid violating this book's (address_book_id, primary_email) uniqueness when - // two cards in the same book share an email — keep the email on the first only. - if (c.primaryEmail && seenInBook.has(c.primaryEmail)) c.primaryEmail = null; - else if (c.primaryEmail) seenInBook.add(c.primaryEmail); +// The number of CardDAV contacts materialized for a user. This is the one authority for +// the integration's contactCount: the ledger row set, not an accumulated total. +async function countMaterializedCarddavContacts(client, userId) { + const { rows: [row] } = await client.query( + `SELECT count(*)::int AS count + FROM carddav_remote_objects o + JOIN address_books b ON b.id = o.address_book_id + WHERE b.user_id = $1 + AND b.source = 'carddav' + AND o.local_contact_id IS NOT NULL + AND o.mapping_status <> 'pending_materialization'`, + [userId], + ); + return row?.count ?? 0; +} - if (c.primaryEmail && dupMode !== 'separate' && otherEmail.has(c.primaryEmail)) { - if (dupMode === 'skip') continue; - if (dupMode === 'merge') { toMerge.push({ id: otherEmail.get(c.primaryEmail), contact: c }); continue; } +// contactCount is derived, never accumulated. Adding a per-book delta to the stored total +// double-counts a book whenever the ledger does not already account for the contacts that +// total was computed from — which is exactly the full-snapshot path (the ledger starts +// empty while an earlier count survives in config). Recounting also self-heals a total +// that is already wrong. +async function persistCarddavContactCount(client, integration, userId) { + const currentCount = carddavContactCount({ contactCount: integration.contact_count }); + const nextCount = await countMaterializedCarddavContacts(client, userId); + if (nextCount === currentCount && integration.contact_count != null) return nextCount; + const updated = await client.query( + `UPDATE user_integrations + SET config = config || jsonb_build_object('contactCount', $2::int), updated_at = NOW() + WHERE id = $1`, + [integration.id, nextCount], + ); + if (updated.rowCount !== 1) throw new StaleCarddavPlanError({ reason: 'not-connected' }); +} + +async function applyAutomaticBookProjection(client, { + plan, + integrationRow, + book, + fingerprintBooks, + replacingAlias, + canonicalUrl, + identity, +}) { + const hasLegacyProjection = integrationRow.has_legacy_projection === true; + const state = await loadAutomaticProjectionState( + client, plan.userId, book.id, hasLegacyProjection, + ); + await validateProjectionFootprint( + client, plan.userId, fingerprintBooks, state.contacts, book.id, + ); + const contactsById = new Map(state.contacts.map(contact => [contact.id, contact])); + const currentMappings = state.mappings.filter(mapping => mapping.addressBookId === book.id); + const currentByHref = new Map(currentMappings.map(mapping => [mapping.href, mapping])); + const incomingByHref = new Map(plan.upserts.map(remote => [remote.href, remote])); + const removedHrefs = new Set(plan.removedHrefs); + const tombstoneHrefs = new Set(currentMappings + .filter(mapping => ( + removedHrefs.has(mapping.href) || (plan.replaceAll && !incomingByHref.has(mapping.href)) + )) + .map(mapping => mapping.href)); + const protectedHrefs = new Set(); + const pendingChanges = []; + const conflictChanges = []; + // Local (non-CardDAV) books whose contacts an inbound change touches, so their + // sync tokens rotate for cache invalidation alongside the CardDAV book's. + const changedLocalBookIds = new Set(); + // Books whose served (presented) document changed from a remote-only edit to + // unmodeled properties, which leaves the modeled columns untouched: rotate so + // getctag pollers re-fetch and the derived served ETag advances. + const presentedChangedBookIds = new Set(); + for (const mapping of currentMappings) { + if (hasLegacyProjection && mapping.legacyProjection) continue; + let remote = incomingByHref.get(mapping.href); + const remoteTombstone = tombstoneHrefs.has(mapping.href); + const contact = contactsById.get(mapping.localContactId); + const currentLocalHash = contact ? localContactHash(contact) : null; + const confirmedLocalHash = mapping.localContactHash ?? currentLocalHash; + const localChanged = mapping.mappingStatus === 'pending_push' + || currentLocalHash !== confirmedLocalHash; + if (!remote && !remoteTombstone) { + if (mapping.mappingStatus !== 'synced' || !localChanged) continue; + remote = { + href: mapping.href, + remoteEtag: mapping.remoteEtag, + vcard: mapping.vcard, + primaryEmail: mapping.primaryEmail, + contact: contactFromVCard(mapping.vcard, mapping.href), + }; } - toUpsert.push(c); + const document = remote ? parseVCardDocument(remote.vcard) : null; + const remoteSemanticHash = document ? semanticVCardHash(document) : null; + const remoteChanged = remoteTombstone + || remoteSemanticHash !== mapping.remoteSemanticHash; + if (mapping.mappingStatus === 'conflict' || (localChanged && remoteChanged)) { + protectedHrefs.add(mapping.href); + conflictChanges.push({ + mapping, + contact, + remote, + document, + remoteSemanticHash, + remoteTombstone, + confirmedLocalHash, + }); + } else if (localChanged) { + protectedHrefs.add(mapping.href); + pendingChanges.push({ + mapping, remote, document, remoteSemanticHash, confirmedLocalHash, + }); + } + } + const removedMappings = currentMappings.filter(mapping => ( + tombstoneHrefs.has(mapping.href) && !protectedHrefs.has(mapping.href) + )); + const removedMappingHrefs = new Set(removedMappings.map(mapping => mapping.href)); + const removedContacts = removedMappings + .map(mapping => ({ mapping, contact: contactsById.get(mapping.localContactId) })) + .filter(entry => entry.contact + && linkedContactFollowsRemote(entry.contact, book.id, entry.mapping.vcard)); + const projectedIds = removedContacts.map(entry => entry.contact.id); + for (const { contact } of removedContacts) { + if (contact.addressBookId !== book.id) changedLocalBookIds.add(contact.addressBookId); } + let removed = 0; + if (projectedIds.length) { + const result = await client.query( + 'DELETE FROM contacts WHERE user_id = $1 AND id = ANY($2::uuid[])', + [plan.userId, projectedIds], + ); + removed = result.rowCount; + for (const id of projectedIds) contactsById.delete(id); + } + if (removedMappings.length) { + for (const mapping of removedMappings) { + assertMappingStateApplied(await applyRemoteTombstone(client, { + addressBookId: mapping.addressBookId, + href: mapping.href, + expectedMappingRevision: mapping.mappingRevision, + }), mapping); + } + } + + const effectiveUpserts = plan.upserts.filter(remote => !protectedHrefs.has(remote.href)); + const upsertsByHref = new Map(effectiveUpserts.map(remote => [remote.href, remote])); + + const remoteObjects = []; + for (const mapping of currentMappings) { + if (removedMappingHrefs.has(mapping.href) || upsertsByHref.has(mapping.href)) continue; + remoteObjects.push({ + href: mapping.href, + remoteEtag: mapping.remoteEtag, + vcard: mapping.vcard, + primaryEmail: mapping.primaryEmail, + contact: contactFromVCard(mapping.vcard, mapping.href), + discoveryIndex: plan.book.discoveryIndex ?? 0, + }); + } + for (const remote of effectiveUpserts) { + remoteObjects.push({ ...remote, discoveryIndex: plan.book.discoveryIndex ?? 0 }); + } + + const retainedMappings = state.mappings + .filter(mapping => ( + mapping.addressBookId !== book.id || !removedMappingHrefs.has(mapping.href) + )) + .filter(mapping => mapping.localContactId && contactsById.has(mapping.localContactId)) + .filter(mapping => mapping.legacyProjection?.disposition !== 'skip') + .map(mapping => ({ href: mapping.href, localContactId: mapping.localContactId })); + const projection = planAutomaticProjection({ + remoteObjects, + mappings: retainedMappings, + localContacts: [...contactsById.values()], + }); + const remotesByHref = new Map(remoteObjects.map(remote => [remote.href, remote])); + const linkedIds = new Map(projection.links.map(link => [link.href, link.localContactId])); + const usedEmails = new Set([...contactsById.values()] + .filter(contact => contact.addressBookId === book.id) + .map(contact => normalizeEmail(contact.primaryEmail)) + .filter(Boolean)); + let refreshed = 0; + for (const link of projection.links) { + const remote = remotesByHref.get(link.href); + const contact = contactsById.get(link.localContactId); + if (!upsertsByHref.has(link.href) || !contact) continue; + // Only meaningful for an EXISTING mapping: compare the presented document before + // and after this remote change (both via the overlay, so formatting is neutral). A + // first-time link has no prior presented representation to have "changed". + const retainedVcard = currentByHref.get(link.href)?.vcard; + const presentedChanged = Boolean(retainedVcard) + && presentedVCard({ ...contact, mapping_vcard: retainedVcard }) + !== presentedVCard({ ...contact, mapping_vcard: remote.vcard }); + if (!linkedContactFollowsRemote(contact, book.id, retainedVcard)) { + // Email-merged: the local contact stays authoritative and is not refreshed, but a + // remote change to unmodeled properties still changes the presented document. + if (presentedChanged) presentedChangedBookIds.add(contact.addressBookId); + continue; + } + const inRemoteBook = contact.addressBookId === book.id; + const remoteEmail = normalizeEmail(remote.contact?.primaryEmail); + let primaryEmail; + if (inRemoteBook) { + // Email uniqueness is per-address-book, so dedupe against the CardDAV book only. + const currentEmail = normalizeEmail(contact.primaryEmail); + if (currentEmail) usedEmails.delete(currentEmail); + primaryEmail = remoteEmail && !usedEmails.has(remoteEmail) ? remoteEmail : null; + if (primaryEmail) usedEmails.add(primaryEmail); + } else { + primaryEmail = remoteEmail; + } + const refresh = await refreshAutomaticImport( + client, plan.userId, book, remote, contact, primaryEmail, + ); + contactsById.set(contact.id, refresh.contact); + if (refresh.changed && !inRemoteBook) changedLocalBookIds.add(contact.addressBookId); + // A remote-only unmodeled change leaves the modeled columns (refresh.changed=false) + // but still advances the presented document → rotate the contact's book. + if (!refresh.changed && presentedChanged) presentedChangedBookIds.add(contact.addressBookId); + refreshed += Number(refresh.changed); + } + let imported = 0; + for (const importPlan of projection.imports) { + const remote = remotesByHref.get(importPlan.href); + const email = normalizeEmail(remote.contact?.primaryEmail); + const primaryEmail = email && !usedEmails.has(email) ? email : null; + if (primaryEmail) usedEmails.add(primaryEmail); + const contact = await materializeAutomaticImport( + client, plan.userId, book, remote, primaryEmail, + ); + contactsById.set(contact.id, contact); + linkedIds.set(remote.href, contact.id); + imported++; + } + + const confirmed = remoteObjects.map(remote => { + const contactId = linkedIds.get(remote.href); + const contact = contactsById.get(contactId); + if (!contact) throw new StaleCarddavPlanError({ reason: 'mapping-contact-missing' }); + return confirmedAutomaticMapping(remote, contactId, contact); + }); + const changedMappingHrefs = new Set(effectiveUpserts.map(remote => remote.href)); + for (const mapping of currentMappings) { + if (mapping.legacyProjection) changedMappingHrefs.add(mapping.href); + } + const changedMappings = confirmed.filter(mapping => changedMappingHrefs.has(mapping.href)); + for (const mapping of changedMappings) { + const current = currentByHref.get(mapping.href); + assertMappingStateApplied(await applyConfirmedRemoteContact(client, { + addressBookId: book.id, + href: mapping.href, + expectedMappingRevision: current?.mappingRevision ?? null, + remoteEtag: mapping.remoteEtag, + vcard: mapping.vcard, + primaryEmail: mapping.primaryEmail, + localContactId: mapping.localContactId, + vcardVersion: mapping.vcardVersion, + remoteSemanticHash: mapping.remoteSemanticHash, + localContactHash: mapping.localContactHash, + supportsPendingIntent: !hasLegacyProjection, + clearLegacyProjection: hasLegacyProjection, + }), current || { + addressBookId: book.id, + href: mapping.href, + mappingRevision: null, + }); + } + for (const change of pendingChanges) { + const { + mapping, remote, document, remoteSemanticHash, confirmedLocalHash, + } = change; + assertMappingStateApplied(await applyConfirmedRemoteContact(client, { + addressBookId: mapping.addressBookId, + href: mapping.href, + expectedMappingRevision: mapping.mappingRevision, + remoteEtag: remote.remoteEtag, + vcard: remote.vcard, + primaryEmail: remote.contact?.primaryEmail ?? remote.primaryEmail ?? null, + localContactId: mapping.localContactId, + vcardVersion: document.version, + remoteSemanticHash, + localContactHash: confirmedLocalHash, + mappingStatus: 'pending_push', + supportsPendingIntent: !hasLegacyProjection, + preservePendingIntent: true, + }), mapping); + } + for (const change of conflictChanges) { + const { + mapping, contact, remote, document, remoteSemanticHash, remoteTombstone, + confirmedLocalHash, + } = change; + assertMappingStateApplied(await refreshUnresolvedConflict(client, { + addressBookId: mapping.addressBookId, + href: mapping.href, + expectedMappingRevision: mapping.mappingRevision, + userId: plan.userId, + baseLocalHash: confirmedLocalHash, + remoteEtag: remote?.remoteEtag ?? null, + primaryEmail: remote?.contact?.primaryEmail ?? remote?.primaryEmail ?? null, + vcardVersion: document?.version ?? null, + remoteSemanticHash, + supportsPendingIntent: !hasLegacyProjection, + preserveLocalSnapshot: mapping.mappingStatus === 'conflict', + localVCard: mapping.mappingStatus === 'conflict' + ? undefined + : conflictLocalSnapshot(mapping, contact), + remoteVCard: remote?.vcard ?? null, + localTombstone: mapping.mappingStatus === 'conflict' ? undefined : !contact, + remoteTombstone, + }), mapping); + } + + const updated = imported + refreshed; + const changedBookIds = [ + ...(imported || refreshed || removed ? [book.id] : []), + ...changedLocalBookIds, + ...presentedChangedBookIds, + ]; + const rotatedBooks = await rotateChangedBookTokens(client, plan.userId, changedBookIds); + const rotatedById = new Map(rotatedBooks.map(row => [row.id, row])); + const finalTargetBooks = fingerprintBooks.map(target => rotatedById.get(target.id) || target); + const fingerprint = projectionFingerprint(finalTargetBooks); + const capabilities = observedCapabilities(plan.book); + const nextRemoteToken = Object.hasOwn(plan, 'nextRemoteToken') + ? plan.nextRemoteToken + : book.remote_sync_token; + let updatedBook; + if (replacingAlias) await client.query('SAVEPOINT carddav_alias_replace'); + try { + updatedBook = await advanceDiscoveredBookState(client, { + addressBookId: book.id, + remoteSyncToken: nextRemoteToken, + remoteSyncCapability: plan.capability, + remoteProjectionFingerprint: fingerprint, + expectedRemoteRevision: plan.expectedRemoteRevision, + canonicalUrl, + capabilities, + }); + } catch (error) { + if (error.code !== '23505' || !replacingAlias) throw error; + await client.query('ROLLBACK TO SAVEPOINT carddav_alias_replace'); + const conflict = await client.query( + `SELECT id FROM address_books + WHERE user_id = $1 AND source = 'carddav' AND external_url = $2 + FOR UPDATE`, + [plan.userId, canonicalUrl], + ); + await client.query('RELEASE SAVEPOINT carddav_alias_replace'); + throw new StaleCarddavPlanError({ + reason: 'canonical-url-conflict', + observedUrl: identity.observedUrl, + canonicalUrl, + conflictingBookId: conflict.rows[0]?.id, + }); + } + if (replacingAlias) await client.query('RELEASE SAVEPOINT carddav_alias_replace'); + if (updatedBook.rowCount !== 1) { + throw new StaleCarddavPlanError({ reason: 'book-update-missed', bookId: book.id }); + } + await persistCarddavContactCount(client, integrationRow, plan.userId); + return { + changedBookIds, + ledgerChanged: removedMappings.length > 0 || changedMappings.length > 0 + || pendingChanges.length > 0 || conflictChanges.length > 0, + updated, + removed, + remote: confirmed.length, + }; +} - const presentUids = toUpsert.map(c => c.uid); - // Delete stale rows BEFORE upserting so a uid/email freed this round can't collide - // with an incoming card (e.g. an email moving to a newly-created contact). - await query( - `DELETE FROM contacts WHERE address_book_id = $1 AND uid <> ALL($2::text[])`, - [bookId, presentUids.length ? presentUids : ['']], +export async function applyBookDelta(client, plan) { + requirePlanFences(plan); + const integration = await client.query( + `SELECT id, + -- Tolerate a half-migrated database applied out-of-band through 0035, not 0036. + EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'carddav_remote_objects' + AND column_name = 'legacy_projection' + ) AS has_legacy_projection, + config->>'connectionGeneration' AS connection_generation, + config->>'contactCount' AS contact_count + FROM user_integrations + WHERE user_id = $1 AND provider = 'carddav' + FOR UPDATE`, + [plan.userId], + ); + const integrationRow = integration.rows[0]; + assertConnectionGeneration( + integrationRow?.connection_generation ?? null, + plan.connectionGeneration, ); - for (const c of toUpsert) await upsertCardavContact(bookId, userId, c); - for (const m of toMerge) await mergeIntoExisting(m.id, m.contact); + if (!integrationRow) { + throw new StaleCarddavPlanError({ + reason: 'connection-generation-changed', + expectedConnectionGeneration: plan.connectionGeneration, + actualConnectionGeneration: null, + }); + } + const identity = plan.collectionIdentity; + const replacingAlias = identity + && identity.observedUrl !== identity.canonicalUrl; + if (replacingAlias && !plan.replaceAll) { + throw new StaleCarddavPlanError({ reason: 'canonical-reconciliation-required' }); + } + const canonicalUrl = plan.replaceAll ? identity?.canonicalUrl : null; + const bookUrls = replacingAlias + ? [identity.observedUrl, identity.canonicalUrl] + : [plan.book.url]; + const lockedBooks = await lockCarddavBooks(client, plan.userId, bookUrls); + let book = lockedBooks.find(row => row.external_url === plan.book.url); + if (!book && replacingAlias) { + throw new StaleCarddavPlanError({ + reason: 'observed-alias-missing', + observedUrl: identity.observedUrl, + }); + } + if (!book) book = await persistDiscoveredBook(client, { userId: plan.userId, ...plan.book }); + if (String(book.remote_sync_revision ?? '0') !== String(plan.expectedRemoteRevision)) { + throw new StaleCarddavPlanError({ + reason: 'remote-revision-changed', + expectedRemoteRevision: plan.expectedRemoteRevision, + actualRemoteRevision: String(book.remote_sync_revision ?? '0'), + }); + } + const expectedRemoteToken = plan.expectedRemoteToken; + if (book.remote_sync_token !== expectedRemoteToken) { + throw new StaleCarddavPlanError({ + reason: 'remote-token-changed', + expectedRemoteToken, + actualRemoteToken: book.remote_sync_token, + }); + } - await query( - "UPDATE address_books SET sync_token = gen_random_uuid()::text, updated_at = NOW() WHERE id = $1", - [bookId], + if (replacingAlias) { + const conflictingBook = lockedBooks.find(row => ( + row.external_url === canonicalUrl && row.id !== book.id + )); + if (conflictingBook) { + throw new StaleCarddavPlanError({ + reason: 'canonical-url-conflict', + observedUrl: identity.observedUrl, + canonicalUrl, + conflictingBookId: conflictingBook.id, + }); + } + } + + const fingerprintBooks = await lockEligibleTargetBooks(client, plan.userId); + await validateTargetBookFootprint(client, plan.userId, fingerprintBooks); + + return applyAutomaticBookProjection(client, { + plan, + integrationRow, + book, + fingerprintBooks, + replacingAlias, + canonicalUrl, + identity, + }); +} + +export async function prepareBookPlan(userId, book, creds) { + const current = await query( + `SELECT remote_sync_token, remote_sync_capability, remote_sync_revision, + connection_generation, book_id + FROM ( + SELECT b.id AS book_id, b.remote_sync_token, b.remote_sync_capability, + b.remote_sync_revision::text, + ui.config->>'connectionGeneration' AS connection_generation + FROM user_integrations ui + LEFT JOIN address_books b + ON b.user_id = ui.user_id AND b.source = 'carddav' AND b.external_url = $2 + WHERE ui.user_id = $1 AND ui.provider = 'carddav' + ) current_book`, + [userId, book.url], + ); + const currentBook = current.rows[0]; + if (!currentBook) throw new StaleCarddavPlanError({ reason: 'not-connected' }); + const expectedRemoteToken = currentBook.remote_sync_token ?? null; + const supportsSyncCollection = Boolean( + book.supportsSyncCollection + && currentBook.remote_sync_capability !== 'snapshot', ); - return { bookId, count: presentUids.length }; + const request = { + ...book, + ...creds, + syncToken: expectedRemoteToken, + supportsSyncCollection, + }; + let delta; + let fallback = 0; + try { + delta = await fetchAddressBookDelta(request); + } catch (error) { + const invalidStoredToken = error?.name === 'CardDavError' + && error.status === 403 + && error.precondition === 'valid-sync-token' + && expectedRemoteToken != null + && expectedRemoteToken !== ''; + if (!invalidStoredToken) throw error; + delta = await fetchAddressBookDelta({ ...request, syncToken: '' }); + delta = { ...delta, expectedRemoteToken }; + fallback = 1; + } + const identity = delta.collectionIdentity; + if (identity && identity.observedUrl !== identity.canonicalUrl && !delta.replaceAll) { + const canonicalDelta = await fetchAddressBookDelta({ + ...request, + url: identity.canonicalUrl, + syncToken: '', + }); + if (!canonicalDelta.replaceAll + || canonicalDelta.collectionIdentity?.canonicalUrl !== identity.canonicalUrl) { + throw new StaleCarddavPlanError({ reason: 'canonical-reconciliation-required' }); + } + delta = { + ...canonicalDelta, + expectedRemoteToken, + collectionIdentity: identity, + }; + fallback = 1; + } + const upserts = delta.upserts.map(card => ({ + href: card.href, + remoteEtag: card.etag ?? null, + vcard: card.vcard, + contact: contactFromVCard(card.vcard, card.href), + })); + return { + userId, + book: { ...book, capabilities: observedCapabilities(book) }, + expectedRemoteToken: delta.expectedRemoteToken, + connectionGeneration: currentBook.connection_generation ?? null, + expectedRemoteRevision: currentBook.remote_sync_revision ?? '0', + nextRemoteToken: delta.nextRemoteToken, + capability: delta.capability, + replaceAll: delta.replaceAll, + collectionIdentity: delta.collectionIdentity, + upserts, + removedHrefs: delta.removedHrefs, + fetched: upserts.length, + fallback: fallback || Number(delta.capability === 'snapshot'), + }; +} + +export async function reconcileStaleCarddavBooks(client, userId, { seenUrls }) { + const { rows: books } = await client.query( + `SELECT id, source, external_url, sync_token + FROM address_books + WHERE user_id = $1 + ORDER BY id + FOR UPDATE`, + [userId], + ); + + const seen = new Set(seenUrls); + const staleBookIds = books + .filter(book => book.source === 'carddav' && !seen.has(book.external_url)) + .map(book => book.id) + .sort(); + if (!staleBookIds.length) return []; + + await client.query( + `DELETE FROM address_books + WHERE user_id = $1 AND id = ANY($2::uuid[])`, + [userId, staleBookIds], + ); + return staleBookIds; +} + +export async function finalizeCarddavSyncTransaction(client, userId, { + connectionGeneration, + seenUrls, + status, +}) { + const integration = await lockCarddavIntegration(client, userId); + if (!integration) throw new StaleCarddavPlanError({ reason: 'not-connected' }); + const actualGeneration = integration.config?.connectionGeneration ?? null; + assertConnectionGeneration(actualGeneration, connectionGeneration); + await reconcileStaleCarddavBooks(client, userId, { seenUrls }); + const contactCount = await countMaterializedCarddavContacts(client, userId); + const updated = await client.query( + `UPDATE user_integrations + SET config = config || $2::jsonb, updated_at = NOW() + WHERE id = $1 AND config->>'connectionGeneration' IS NOT DISTINCT FROM $3`, + [ + integration.id, + JSON.stringify({ ...status, contactCount }), + connectionGeneration, + ], + ); + if (updated.rowCount !== 1) { + throw new StaleCarddavPlanError({ + reason: 'connection-generation-changed', + expectedConnectionGeneration: connectionGeneration, + actualConnectionGeneration: actualGeneration, + }); + } + return contactCount; } -export async function syncUser(userId) { - const config = await getCardavConfig(userId); - if (!config?.serverUrl) return { ok: false, error: 'not connected' }; - if (syncing.has(userId)) return { ok: false, error: 'A sync is already in progress' }; +export async function disconnectCarddavTransaction(client, userId) { + const integration = await lockCarddavIntegration(client, userId); + if (!integration) return false; + const actualGeneration = integration.config?.connectionGeneration ?? null; + await reconcileStaleCarddavBooks(client, userId, { seenUrls: [] }); + const deleted = await client.query( + `DELETE FROM user_integrations + WHERE id = $1 AND config->>'connectionGeneration' IS NOT DISTINCT FROM $2 + RETURNING id`, + [integration.id, actualGeneration], + ); + if (deleted.rowCount !== 1) { + throw new StaleCarddavPlanError({ + reason: 'connection-generation-changed', + expectedConnectionGeneration: actualGeneration, + actualConnectionGeneration: null, + }); + } + return true; +} + +export async function finalizeCarddavSync(userId, options) { + return withTransaction(client => finalizeCarddavSyncTransaction(client, userId, options)); +} + +export async function disconnectCarddavAccount(userId) { + const disconnected = await withTransaction(client => disconnectCarddavTransaction(client, userId)); + stopCardavUser(userId); + pendingReplacementSyncs.delete(userId); + return disconnected; +} + +export async function recordCarddavSyncFailure(userId, expectedGeneration, error) { + const patch = { + lastError: error.message, + lastSyncAt: new Date().toISOString(), + ...(error.retryAfterAt ? { retryAfterAt: error.retryAfterAt } : {}), + }; + const result = await query( + `UPDATE user_integrations SET config = config || $2::jsonb, updated_at = NOW() + WHERE user_id = $1 AND provider = 'carddav' + AND config->>'connectionGeneration' IS NOT DISTINCT FROM $3`, + [userId, JSON.stringify(patch), expectedGeneration], + ); + return result.rowCount === 1; +} + +async function unmappedExplicitContactIds(userId) { + const { rows } = await query( + `SELECT c.id + FROM contacts c + WHERE c.user_id = $1 AND c.is_auto = false + AND NOT EXISTS ( + SELECT 1 FROM carddav_remote_objects mapping + WHERE mapping.local_contact_id = c.id + ) + ORDER BY c.id`, + [userId], + ); + return rows.map(row => row.id); +} + +export function requestCarddavSync(userId, connectionGeneration) { + if (syncing.has(userId)) { + if (activeSyncGenerations.get(userId) !== connectionGeneration) { + pendingReplacementSyncs.add(userId); + } + return false; + } + syncUser(userId, connectionGeneration).catch(() => {}); + return true; +} + +export async function syncUser(userId, expectedGeneration) { + const counters = emptyResultCounters(); + if (syncing.has(userId)) { + if (expectedGeneration !== undefined + && activeSyncGenerations.get(userId) !== expectedGeneration) { + pendingReplacementSyncs.add(userId); + } + return { ok: false, error: 'A sync is already in progress', ...counters }; + } syncing.add(userId); - const policy = await getConnectionPolicy(); - const allowPrivate = policy.allowPrivateHosts; - const creds = { username: config.username, password: decrypt(config.password), allowPrivate }; + activeSyncGenerations.set(userId, expectedGeneration); + let config; try { + config = await getCardavConfig(userId); + if (!config?.serverUrl) return { ok: false, error: 'not connected', ...counters }; + assertConnectionGeneration(config.connectionGeneration, expectedGeneration); + const retryAfterAt = activeRetryAfterAt(config); + if (retryAfterAt) { + throw new CardDavError('CardDAV requests are throttled until Retry-After eligibility', { + status: 429, + operation: 'sync', + retryAfterAt, + }); + } + activeSyncGenerations.set(userId, config.connectionGeneration); + const creds = await resolveCarddavCredentials(config); const books = await discoverAddressBooks({ serverUrl: config.serverUrl, ...creds }); - let contactCount = 0; - const seenUrls = []; + await recoverPendingCarddavMutations(userId, { + integration: { config }, + creds, + }); + const plans = []; for (const book of books) { - const { count } = await syncBook(userId, book, config.dupMode || 'separate', creds); - contactCount += count; - seenUrls.push(book.url); - } - // Prune local CardDAV books whose remote collection disappeared (cascades to contacts). - await query( - `DELETE FROM address_books - WHERE user_id = $1 AND source = 'carddav' AND external_url <> ALL($2::text[])`, - [userId, seenUrls.length ? seenUrls : ['']], - ); - await saveCardavConfig(userId, { - lastSyncAt: new Date().toISOString(), - lastError: null, bookCount: books.length, contactCount, + plans.push(await prepareBookPlan(userId, book, creds)); + } + + for (let index = 0; index < plans.length; index++) { + let plan = plans[index]; + let applied; + try { + applied = await withTransaction(client => applyBookDelta(client, plan)); + } catch (error) { + if (!(error instanceof StaleCarddavPlanError)) throw error; + switch (stalePlanAction(error.reason)) { + case 'retry-apply': + applied = await withTransaction(client => applyBookDelta(client, plan)); + break; + case 'refetch-once': { + const freshConfig = await getCardavConfig(userId); + if (freshConfig?.connectionGeneration !== undefined + && freshConfig.connectionGeneration !== plan.connectionGeneration) { + throw error; + } + plan = await prepareBookPlan(userId, books[index], creds); + applied = await withTransaction(client => applyBookDelta(client, plan)); + break; + } + case 'abort': + throw error; + } + } + plans[index] = plan; + addResultCounters(counters, { + fetched: plan.fetched, + fallback: plan.fallback, + remote: applied.remote, + updated: applied.updated, + removed: applied.removed, + }); + } + const exportFailures = []; + for (const contactId of await unmappedExplicitContactIds(userId)) { + try { + await exportExistingContact(userId, contactId, { + books, + expectedGeneration: config.connectionGeneration, + }); + } catch (error) { + if (error instanceof CardDavError + && error.status === 429 + && activeRetryAfterAt(error)) { + throw error; + } + exportFailures.push({ + localContactId: contactId, + code: error.code || 'ERR_CARDDAV_EXPORT', + message: error.message, + }); + } + } + const seenUrls = plans.map(plan => ( + plan.collectionIdentity?.canonicalUrl ?? plan.book.url + )); + const contactCount = await finalizeCarddavSync(userId, { + seenUrls, + connectionGeneration: config.connectionGeneration, + status: { + lastSyncAt: new Date().toISOString(), + lastError: null, + bookCount: books.length, + exportFailures, + ...(Object.hasOwn(config, 'retryAfterAt') ? { retryAfterAt: null } : {}), + }, }); - return { ok: true, bookCount: books.length, contactCount }; + return { + ok: true, + bookCount: books.length, + contactCount, + exportFailures, + ...counters, + }; } catch (err) { - await saveCardavConfig(userId, { lastError: err.message, lastSyncAt: new Date().toISOString() }); - return { ok: false, error: err.message }; + if (config && err.reason !== 'not-connected') { + const statusGeneration = expectedGeneration !== undefined + ? expectedGeneration + : config.connectionGeneration; + const gatedRetry = err instanceof CardDavError + && err.status === 429 + && err.requestStatus == null + && err.retryAfterAt; + if (!gatedRetry) await recordCarddavSyncFailure(userId, statusGeneration, err); + } + return { + ok: false, + error: err.message, + ...(err.retryAfterAt ? { retryAfterAt: err.retryAfterAt } : {}), + ...counters, + }; } finally { syncing.delete(userId); + activeSyncGenerations.delete(userId); + if (pendingReplacementSyncs.delete(userId)) { + queueMicrotask(() => requestCarddavSync(userId)); + } } } @@ -215,6 +1370,16 @@ export function stopCardavUser(userId) { } export async function startCardavScheduler() { + if (conflictCleanupTimer) clearInterval(conflictCleanupTimer); + conflictCleanupTimer = setInterval(() => withTransaction(client => ( + deleteResolvedConflictsBefore( + client, + new Date(Date.now() - CONFLICT_RETENTION_MS), + ) + )).catch(error => { + console.warn('CardDAV conflict cleanup failed:', error.message); + }), CONFLICT_CLEANUP_INTERVAL_MS); + conflictCleanupTimer.unref?.(); try { const rows = await query("SELECT user_id, config FROM user_integrations WHERE provider = 'carddav'"); for (const row of rows.rows) { diff --git a/backend/src/services/carddavSync.test.js b/backend/src/services/carddavSync.test.js new file mode 100644 index 00000000..47558a6f --- /dev/null +++ b/backend/src/services/carddavSync.test.js @@ -0,0 +1,2037 @@ +import { createHash } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + localContactHash, + parseVCardDocument, + semanticVCardHash, +} from '../utils/vcardProperties.js'; + +const mocks = vi.hoisted(() => ({ + decrypt: vi.fn(value => value), + deleteResolvedConflictsBefore: vi.fn(), + discoverAddressBooks: vi.fn(), + exportExistingContact: vi.fn(), + fetchAddressBookDelta: vi.fn(), + getConnectionPolicy: vi.fn(async () => ({ allowPrivateHosts: false })), + parseVCard: vi.fn(), + query: vi.fn(), + recoverPendingCarddavMutations: vi.fn(), + withTransaction: vi.fn(), +})); + +vi.mock('./db.js', () => ({ + query: mocks.query, + withTransaction: mocks.withTransaction, +})); +vi.mock('./encryption.js', () => ({ decrypt: mocks.decrypt })); +vi.mock('./connectionPolicy.js', () => ({ getConnectionPolicy: mocks.getConnectionPolicy })); +vi.mock('./carddavClient.js', () => ({ + discoverAddressBooks: mocks.discoverAddressBooks, + fetchAddressBookDelta: mocks.fetchAddressBookDelta, +})); +vi.mock('./carddavContactService.js', () => ({ + exportExistingContact: mocks.exportExistingContact, + recoverPendingCarddavMutations: mocks.recoverPendingCarddavMutations, +})); +vi.mock('./carddavConflictService.js', () => ({ + deleteResolvedConflictsBefore: mocks.deleteResolvedConflictsBefore, +})); +vi.mock('../utils/vcard.js', async importOriginal => { + const original = await importOriginal(); + mocks.parseVCard.mockImplementation(original.parseVCard); + return { ...original, parseVCard: mocks.parseVCard }; +}); + +const carddavSync = await import('./carddavSync.js'); +const { CardDavError } = await vi.importActual('./carddavTransport.js'); +const { generateVCard } = await vi.importActual('../utils/vcard.js'); +const USER_ID = '00000000-0000-4000-8000-000000000001'; +const BOOK_ID = '00000000-0000-4000-8000-000000000002'; +const TARGET_ID = '00000000-0000-4000-8000-000000000004'; +const LOCAL_BOOK_ID = '00000000-0000-4000-8000-000000000005'; +const BOOK_URL = 'https://dav.example.test/addressbooks/default/'; +const ALIAS_BOOK_URL = 'https://dav.example.test/addressbooks/alias/'; +const CANONICAL_BOOK_URL = 'https://dav.example.test/addressbooks/canonical/'; +const CONNECTION_GENERATION = 'generation-current'; +const EMPTY_COUNTERS = { + remote: 0, + fetched: 0, + updated: 0, + removed: 0, + fallback: 0, +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +const STALE_REASON_POLICIES = [ + ['invalid-plan-fence', 'abort', 0], + ['not-connected', 'abort', 0], + ['connection-generation-changed', 'abort', 0], + ['projection-footprint-changed', 'retry-apply', 1], + ['mapping-revision-changed', 'abort', 0], + ['mapping-contact-missing', 'abort', 0], + ['canonical-url-conflict', 'abort', 0], + ['book-update-missed', 'abort', 0], + ['canonical-reconciliation-required', 'abort', 0], + ['observed-alias-missing', 'abort', 0], + ['remote-revision-changed', 'refetch-once', 1], + ['remote-token-changed', 'refetch-once', 1], +]; + +function completePlan(overrides = {}) { + return { + userId: USER_ID, + book: { url: BOOK_URL, displayName: 'Remote' }, + connectionGeneration: CONNECTION_GENERATION, + expectedRemoteRevision: '0', + expectedRemoteToken: null, + nextRemoteToken: null, + capability: 'snapshot', + replaceAll: true, + upserts: [], + removedHrefs: [], + ...overrides, + }; +} + +function applyClient({ + remoteToken = null, + remoteRevision = '0', + connectionGeneration = CONNECTION_GENERATION, + integrationPresent = true, + bookPresent = true, + bookUpdateCount = 1, + lifecycleBooks = [], + contactCount = 0, + ledger = new Set(), +} = {}) { + return { + query: vi.fn(async (sql, params) => { + if (/SELECT id, config\s+FROM user_integrations[\s\S]+FOR UPDATE/.test(sql)) { + return integrationPresent ? { + rows: [{ + id: '00000000-0000-4000-8000-000000000006', + config: { + connectionGeneration, + contactCount, + }, + }], + } : { rows: [] }; + } + if (/SELECT id, source, external_url, sync_token[\s\S]+FROM address_books/.test(sql)) { + return { rows: lifecycleBooks }; + } + if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) { + return bookPresent ? { + rows: [{ + id: BOOK_ID, + external_url: params[1][0], + remote_sync_token: remoteToken, + remote_sync_revision: remoteRevision, + sync_token: 'local-token-before', + }], + } : { rows: [] }; + } + if (/FROM user_integrations[\s\S]+FOR (?:SHARE|UPDATE)/.test(sql)) { + return integrationPresent + ? { rows: [{ + id: '00000000-0000-4000-8000-000000000006', + has_legacy_projection: false, + connection_generation: connectionGeneration, + contact_count: String(contactCount), + }] } + : { rows: [] }; + } + // The materialized-mapping recount that derives contactCount: answer it from the + // mapping rows this fake ledger has actually taken, so the count is observed rather + // than accumulated (as it is in PostgreSQL). + if (/count\(\*\)::int AS count[\s\S]+FROM carddav_remote_objects/.test(sql)) { + return { rows: [{ count: ledger.size }] }; + } + if (sql.includes('FROM carddav_remote_objects')) return { rows: [] }; + if (sql.includes('FROM contacts')) return { rows: [] }; + if (sql.includes('INSERT INTO contacts')) { + return { rows: [{ id: '00000000-0000-4000-8000-000000000003' }], rowCount: 1 }; + } + if (sql.includes('INSERT INTO carddav_remote_objects')) { + if (params[5]) ledger.add(`${params[0]}|${params[1]}`); + return { rows: [{ mapping_revision: '0' }], rowCount: 1 }; + } + if (/UPDATE user_integrations/.test(sql)) { + if (/jsonb_build_object\('contactCount'/.test(sql)) contactCount = params[1]; + else if (/config = config \|\| \$2::jsonb/.test(sql)) { + contactCount = JSON.parse(params[1]).contactCount ?? contactCount; + } + return { rows: [], rowCount: 1 }; + } + if (/UPDATE address_books\s+SET/.test(sql)) return { rows: [], rowCount: bookUpdateCount }; + return { rows: [], rowCount: 0 }; + }), + }; +} + +function projectionClient({ + objects = [], + contacts = [], + connectionGeneration = CONNECTION_GENERATION, + contactCount = 0, +} = {}) { + return { + query: vi.fn(async (sql, params) => { + if (/external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql)) { + return { + rows: [{ + id: BOOK_ID, + external_url: BOOK_URL, + remote_sync_token: null, + remote_sync_revision: '0', + sync_token: 'local-token-before', + }], + }; + } + if (/FROM address_books[\s\S]+source <> 'carddav'[\s\S]+FOR UPDATE/.test(sql)) { + const books = new Map(contacts + .filter(contact => contact.address_book_source !== 'carddav') + .map(contact => [contact.address_book_id, { + id: contact.address_book_id, + source: 'local', + sync_token: 'target-token-before', + }])); + return { rows: [...books.values()] }; + } + if (/SELECT id[\s\S]+source <> 'carddav'/.test(sql)) { + const ids = [...new Set(contacts + .filter(contact => contact.address_book_source !== 'carddav') + .map(contact => contact.address_book_id))]; + return { rows: ids.sort().map(id => ({ + id, + source: 'local', + sync_token: 'target-token-before', + })) }; + } + if (/FROM user_integrations[\s\S]+FOR (?:SHARE|UPDATE)/.test(sql)) { + return { rows: [{ + id: '00000000-0000-4000-8000-000000000006', + has_legacy_projection: false, + connection_generation: connectionGeneration, + contact_count: String(contactCount), + }] }; + } + if (/FROM carddav_remote_objects[\s\S]+address_book_id <>/.test(sql)) return { rows: [] }; + if (/^(?:\s*)(?:INSERT INTO|UPDATE|DELETE FROM) carddav_remote_objects/.test(sql)) { + return { rows: [{ mapping_revision: '1' }], rowCount: 1 }; + } + if (sql.includes('FROM carddav_remote_objects')) return { rows: objects }; + if (/SELECT c\.id[\s\S]+FROM contacts/.test(sql) && !sql.includes('c.uid')) { + return { rows: contacts.map(contact => ({ id: contact.id })) }; + } + if (/SELECT[\s\S]+FROM contacts/.test(sql)) return { rows: contacts }; + if (sql.includes('INSERT INTO contacts')) { + return { rows: [{ id: '00000000-0000-4000-8000-000000000003' }], rowCount: 1 }; + } + if (/UPDATE address_books[\s\S]+RETURNING id, source, sync_token/.test(sql)) { + return { + rows: params[1].map(id => ({ id, source: 'local', sync_token: `${id}-after` })), + rowCount: params[1].length, + }; + } + if (/UPDATE user_integrations/.test(sql)) { + if (/jsonb_build_object\('contactCount'/.test(sql)) contactCount = params[1]; + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }), + }; +} + +function targetContactRow(overrides = {}) { + const vcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:local-target', + 'FN:Original Name', + 'N:Name;Original;;;', + 'EMAIL:duplicate@example.test', + 'END:VCARD', + '', + ].join('\r\n'); + return { + id: TARGET_ID, + address_book_id: LOCAL_BOOK_ID, + address_book_source: 'local', + uid: 'local-target', + vcard, + etag: createHash('md5').update(vcard).digest('hex'), + display_name: 'Original Name', + first_name: 'Original', + last_name: 'Name', + primary_email: 'duplicate@example.test', + emails: [{ value: 'duplicate@example.test', type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photo_data: null, + additional_fields: [], + is_auto: false, + ...overrides, + }; +} + +function remoteCard(name, email) { + const href = `${BOOK_URL}${name}.vcf`; + const vcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `UID:remote-${name}`, + `FN:${name}`, + `N:${name};${name};;;`, + `EMAIL:${email}`, + 'END:VCARD', + '', + ].join('\r\n'); + return { + href, + remoteEtag: `W/"remote-etag-${name}"`, + vcard, + contact: { + uid: `remote-${name}`, + displayName: name, + firstName: name, + lastName: name, + primaryEmail: email, + emails: [{ value: email, type: 'other', primary: true }], + phones: [], + organization: null, + notes: null, + photoData: null, + }, + }; +} + +function persistedSeparate(name, email) { + const card = remoteCard(name, email); + const localUid = createHash('sha256').update(card.href).digest('hex'); + const vcard = generateVCard({ ...card.contact, uid: localUid }); + return { + card, + object: { + address_book_id: BOOK_ID, + href: card.href, + remote_etag: card.remoteEtag, + vcard: card.vcard, + primary_email: email, + disposition: 'separate', + local_contact_id: TARGET_ID, + merge_before: null, + merge_applied: null, + }, + contact: targetContactRow({ + address_book_id: BOOK_ID, + address_book_source: 'carddav', + uid: localUid, + vcard, + etag: createHash('md5').update(vcard).digest('hex'), + display_name: name, + first_name: name, + last_name: name, + primary_email: email, + emails: [{ value: email, type: 'other', primary: true }], + }), + }; +} + +function configureOneBookSync({ remoteToken = null } = {}) { + mocks.query.mockImplementation(async (sql, params) => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { + rows: [{ + config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + connectionGeneration: CONNECTION_GENERATION, + }, + }], + }; + } + if (sql.includes('SELECT remote_sync_token')) { + expect(params).toEqual([USER_ID, BOOK_URL]); + return { rows: [{ + remote_sync_token: remoteToken, + remote_sync_capability: 'sync-collection', + remote_sync_revision: '0', + connection_generation: CONNECTION_GENERATION, + }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([ + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + ]); +} + +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +describe('disconnectCarddavAccount', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns false without lifecycle mutation when the integration is already absent', async () => { + const client = applyClient({ integrationPresent: false }); + mocks.withTransaction.mockImplementation(callback => callback(client)); + + await expect(carddavSync.disconnectCarddavAccount(USER_ID)).resolves.toBe(false); + + expect(client.query).toHaveBeenCalledOnce(); + expect(client.query.mock.calls[0][0]).toMatch(/FROM user_integrations[\s\S]+FOR UPDATE/); + }); + + it('reconciles stale books through one helper that returns changed book IDs', async () => { + const client = applyClient({ + contactCount: 2, + lifecycleBooks: [{ + id: BOOK_ID, + source: 'carddav', + external_url: BOOK_URL, + sync_token: 'local-before', + }], + }); + + await expect(carddavSync.reconcileStaleCarddavBooks(client, USER_ID, { + seenUrls: [], + })).resolves.toEqual([BOOK_ID]); + }); + + it('keeps finalization status-only and disconnect integration-delete-only', async () => { + const finalizationClient = applyClient(); + await carddavSync.finalizeCarddavSyncTransaction(finalizationClient, USER_ID, { + connectionGeneration: CONNECTION_GENERATION, + seenUrls: [], + status: { lastError: null }, + }); + const finalizationMutations = finalizationClient.query.mock.calls + .map(([sql]) => sql) + .filter(sql => /(?:UPDATE|DELETE FROM) user_integrations/.test(sql)); + expect(finalizationMutations).toHaveLength(1); + expect(finalizationMutations[0]).toContain('UPDATE user_integrations'); + + const disconnectClient = applyClient(); + const query = disconnectClient.query.getMockImplementation(); + disconnectClient.query.mockImplementation(async (sql, params) => ( + sql.includes('DELETE FROM user_integrations') + ? { rows: [{ id: USER_ID }], rowCount: 1 } + : query(sql, params) + )); + await carddavSync.disconnectCarddavTransaction(disconnectClient, USER_ID); + const disconnectMutations = disconnectClient.query.mock.calls + .map(([sql]) => sql) + .filter(sql => /(?:UPDATE|DELETE FROM) user_integrations/.test(sql)); + expect(disconnectMutations).toHaveLength(1); + expect(disconnectMutations[0]).toContain('DELETE FROM user_integrations'); + }); +}); + +describe('applyBookDelta', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('is exported as the transactional CardDAV apply boundary', () => { + expect(carddavSync.applyBookDelta).toBeTypeOf('function'); + }); + + it.each(STALE_REASON_POLICIES)( + 'maps stale reason %s to %s with at most %i retry', + (reason, action, maximumRetryCount) => { + const selected = carddavSync.stalePlanAction(reason); + expect(selected).toBe(action); + expect({ abort: 0, 'retry-apply': 1, 'refetch-once': 1 }[selected]) + .toBe(maximumRetryCount); + }, + ); + + it('rejects unknown stale reasons and ignores diagnostic fields when selecting policy', () => { + expect(() => carddavSync.stalePlanAction('unknown')).toThrow(/unknown stale/i); + const stale = Object.assign( + new carddavSync.StaleCarddavPlanError({ reason: 'mapping-contact-missing' }), + { + expectedRemoteToken: 'before', + actualRemoteToken: 'after', + expectedConnectionGeneration: 'before', + actualConnectionGeneration: 'before', + }, + ); + expect(carddavSync.stalePlanAction(stale.reason)).toBe('abort'); + }); + + it('rejects an unfenced plan before executing SQL', async () => { + const client = applyClient(); + const plan = completePlan(); + delete plan.connectionGeneration; + + await expect(carddavSync.applyBookDelta(client, plan)).rejects.toMatchObject({ + reason: 'invalid-plan-fence', + }); + expect(client.query).not.toHaveBeenCalled(); + }); + + it('uses only the passed client and locks the integration before the mirrored book', async () => { + mocks.query.mockRejectedValue(new Error('global query must not be used')); + const client = applyClient(); + + await carddavSync.applyBookDelta(client, completePlan()); + + expect(mocks.query).not.toHaveBeenCalled(); + expect(client.query).toHaveBeenCalled(); + expect(client.query.mock.calls[0][0]).toMatch( + /SELECT[\s\S]+FROM user_integrations[\s\S]+FOR UPDATE/, + ); + expect(client.query.mock.calls[1][0]).toMatch( + /SELECT[\s\S]+FROM address_books[\s\S]+FOR UPDATE/, + ); + }); + + it('locks eligible target books before locking projection contacts', async () => { + const client = applyClient(); + + await carddavSync.applyBookDelta(client, completePlan()); + + const queries = client.query.mock.calls.map(([sql]) => sql); + const targetBooks = queries.findIndex(sql => ( + /FROM address_books/.test(sql) && /source <> 'carddav'/.test(sql) && /FOR UPDATE/.test(sql) + )); + const contacts = queries.findIndex(sql => /FROM contacts/.test(sql)); + expect(targetBooks).toBeGreaterThan(0); + expect(contacts).toBeGreaterThan(targetBooks); + expect(queries[contacts]).toMatch(/ORDER BY c\.id[\s\S]+FOR UPDATE OF c/); + }); + + it('increments the remote revision in the final guarded book update', async () => { + const client = applyClient(); + + await carddavSync.applyBookDelta(client, completePlan()); + + const update = client.query.mock.calls.find(([sql]) => ( + /UPDATE address_books SET/.test(sql) + )); + expect(update?.[0]).toMatch( + /remote_sync_revision = remote_sync_revision \+ 1/, + ); + }); + + it('rejects a stale connection generation before book lookup or creation', async () => { + const client = applyClient({ connectionGeneration: 'generation-new' }); + + const error = await carddavSync.applyBookDelta(client, completePlan()).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + expectedConnectionGeneration: CONNECTION_GENERATION, + actualConnectionGeneration: 'generation-new', + }); + expect(client.query).toHaveBeenCalledOnce(); + expect(client.query.mock.calls[0][0]).toMatch(/FROM user_integrations[\s\S]+FOR UPDATE/); + }); + + it('treats a missing integration as stale before book lookup or creation', async () => { + const client = applyClient({ integrationPresent: false }); + + const error = await carddavSync.applyBookDelta(client, completePlan()).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + expectedConnectionGeneration: CONNECTION_GENERATION, + actualConnectionGeneration: null, + }); + expect(client.query).toHaveBeenCalledOnce(); + }); + + it('rejects a stale remote revision before reading projection state', async () => { + const client = applyClient({ remoteRevision: '8' }); + + const error = await carddavSync.applyBookDelta(client, completePlan({ + expectedRemoteRevision: '7', + })).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + expectedRemoteRevision: '7', + actualRemoteRevision: '8', + }); + expect(client.query).toHaveBeenCalledTimes(2); + expect(client.query.mock.calls.some(([sql]) => /FROM carddav_remote_objects/.test(sql))) + .toBe(false); + }); + + it('rejects a stale opaque remote token before reading or mutating projection state', async () => { + const client = applyClient({ remoteToken: 'opaque-token' }); + + const error = await carddavSync.applyBookDelta(client, completePlan({ + expectedRemoteToken: ' opaque-token ', + })).catch(caught => caught); + + expect(carddavSync.StaleCarddavPlanError).toBeTypeOf('function'); + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + expectedRemoteToken: ' opaque-token ', + actualRemoteToken: 'opaque-token', + }); + expect(client.query).toHaveBeenCalledTimes(2); + }); + + it('rejects an incremental alias replacement before reading projection state', async () => { + const client = applyClient(); + + const error = await carddavSync.applyBookDelta(client, completePlan({ + book: { url: ALIAS_BOOK_URL, displayName: 'Remote' }, + replaceAll: false, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + })).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ reason: 'canonical-reconciliation-required' }); + expect(client.query).toHaveBeenCalledOnce(); + expect(client.query.mock.calls.some(([sql]) => /FROM carddav_remote_objects/.test(sql))) + .toBe(false); + }); + + it('rejects a missing observed alias instead of recreating it', async () => { + const client = applyClient({ bookPresent: false }); + + const error = await carddavSync.applyBookDelta(client, completePlan({ + book: { url: ALIAS_BOOK_URL, displayName: 'Remote' }, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + })).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ + reason: 'observed-alias-missing', + observedUrl: ALIAS_BOOK_URL, + }); + expect(client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO address_books'))) + .toBe(false); + }); + + it('locks observed and canonical books together in stable id order', async () => { + const client = applyClient(); + + await carddavSync.applyBookDelta(client, completePlan({ + book: { url: ALIAS_BOOK_URL, displayName: 'Remote' }, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + })); + + const bookLocks = client.query.mock.calls.filter(([sql]) => ( + /external_url = ANY\(\$2::text\[\]\)[\s\S]+FOR UPDATE/.test(sql) + )); + expect(bookLocks).toHaveLength(1); + expect(bookLocks[0][0]).toMatch(/external_url = ANY\(\$2::text\[\]\)[\s\S]+ORDER BY id[\s\S]+FOR UPDATE/); + expect(bookLocks[0][1]).toEqual([ + USER_ID, + [ALIAS_BOOK_URL, CANONICAL_BOOK_URL], + ]); + }); + + it('rejects a zero-row final book update as stale', async () => { + const client = applyClient({ bookUpdateCount: 0 }); + + const error = await carddavSync.applyBookDelta(client, completePlan()) + .catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ reason: 'book-update-missed', bookId: BOOK_ID }); + }); + + it('recovers from an address-book name collision without aborting the transaction', async () => { + let insertAttempts = 0; + const client = { + query: vi.fn(async sql => { + if (/FROM address_books[\s\S]+FOR UPDATE/.test(sql)) return { rows: [] }; + if (sql.includes('INSERT INTO address_books')) { + insertAttempts++; + if (insertAttempts === 1) { + const error = new Error('duplicate name'); + error.code = '23505'; + throw error; + } + return { + rows: [{ + id: BOOK_ID, + external_url: BOOK_URL, + remote_sync_token: null, + remote_sync_revision: '0', + sync_token: 'local-token-before', + }], + }; + } + if (/FROM user_integrations[\s\S]+FOR (?:SHARE|UPDATE)/.test(sql)) { + return { rows: [{ + id: USER_ID, + dup_mode: 'separate', + connection_generation: CONNECTION_GENERATION, + contact_count: '0', + }] }; + } + if (sql.includes('FROM carddav_remote_objects')) return { rows: [] }; + if (sql.includes('FROM contacts')) return { rows: [] }; + if (/UPDATE address_books SET/.test(sql)) return { rows: [], rowCount: 1 }; + if (/UPDATE user_integrations/.test(sql)) return { rows: [], rowCount: 1 }; + return { rows: [], rowCount: 0 }; + }), + }; + + await carddavSync.applyBookDelta(client, completePlan()); + + expect(insertAttempts).toBe(2); + expect(client.query.mock.calls.map(([sql]) => sql)).toContain('ROLLBACK TO SAVEPOINT carddav_book_name'); + }); + + it('preserves the exact remote ETag in the provenance ledger', async () => { + const client = applyClient(); + const card = remoteCard('quoted-etag', 'quoted@example.test'); + + await carddavSync.applyBookDelta(client, completePlan({ upserts: [card] })); + + const ledgerInsert = client.query.mock.calls.find(([sql]) => ( + sql.includes('INSERT INTO carddav_remote_objects') + )); + expect(ledgerInsert[1][2]).toBe('W/"remote-etag-quoted-etag"'); + }); + + it('performs no ledger write for a no-change incremental plan', async () => { + const fixture = persistedSeparate('unchanged', 'unchanged@example.test'); + const client = projectionClient({ + objects: [fixture.object], + contacts: [fixture.contact], + }); + + const result = await carddavSync.applyBookDelta(client, completePlan({ + replaceAll: false, + })); + + const ledgerWrites = client.query.mock.calls.filter(([sql]) => ( + /(?:INSERT INTO|DELETE FROM|UPDATE) carddav_remote_objects/.test(sql) + )); + expect(ledgerWrites).toEqual([]); + expect(result).toMatchObject({ ledgerChanged: false, changedBookIds: [] }); + }); + + it('classifies an unreported local-only edit as pending push', async () => { + const fixture = persistedSeparate('local-only', 'local-only@example.test'); + fixture.object.mapping_status = 'synced'; + fixture.object.mapping_revision = '4'; + fixture.object.remote_semantic_hash = semanticVCardHash(parseVCardDocument(fixture.card.vcard)); + fixture.object.local_contact_hash = localContactHash(fixture.contact); + fixture.contact.display_name = 'Local Only Changed'; + const client = projectionClient({ + objects: [fixture.object], + contacts: [fixture.contact], + }); + + const result = await carddavSync.applyBookDelta(client, completePlan({ + replaceAll: false, + })); + + const mappingWrite = client.query.mock.calls.find(([sql]) => ( + /UPDATE carddav_remote_objects/.test(sql) + )); + expect(mappingWrite[0]).toContain("mapping_status = 'pending_push'"); + expect(result).toMatchObject({ + ledgerChanged: true, + changedBookIds: [], + updated: 0, + removed: 0, + }); + }); + + it('writes only the changed href for a ledger-only ETag delta', async () => { + const fixture = persistedSeparate('etag-only', 'etag-only@example.test'); + const client = projectionClient({ + objects: [fixture.object], + contacts: [fixture.contact], + }); + const changed = { ...fixture.card, remoteEtag: 'W/"changed-etag"' }; + + const result = await carddavSync.applyBookDelta(client, completePlan({ + replaceAll: false, + upserts: [changed], + })); + + const ledgerWrites = client.query.mock.calls.filter(([sql]) => ( + /(?:INSERT INTO|DELETE FROM|UPDATE) carddav_remote_objects/.test(sql) + )); + expect(ledgerWrites).toHaveLength(1); + expect(ledgerWrites[0][0]).toContain('UPDATE carddav_remote_objects'); + expect(ledgerWrites[0][1]).toEqual(expect.arrayContaining([ + fixture.card.href, + 'W/"changed-etag"', + ])); + expect(result).toMatchObject({ ledgerChanged: true, changedBookIds: [] }); + }); + + it('regenerates a separate local vCard and ETag from its final contact fields', async () => { + const client = applyClient(); + const card = remoteCard('local-materialization', 'materialized@example.test'); + + await carddavSync.applyBookDelta(client, completePlan({ upserts: [card] })); + + const contactInsert = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO contacts')); + const localUid = createHash('sha256').update(card.href).digest('hex'); + expect(contactInsert[1][2]).toBe(localUid); + expect(contactInsert[1][3]).toContain(`UID:${localUid}\r\n`); + expect(contactInsert[1][3]).toContain('FN:local-materialization\r\n'); + expect(contactInsert[1][3]).not.toBe(card.vcard); + expect(contactInsert[1][4]).toBe(createHash('md5').update(contactInsert[1][3]).digest('hex')); + }); + + it('reports automatic projection counters from the transactional apply boundary', async () => { + const client = projectionClient({ contacts: [targetContactRow()] }); + const cards = [ + remoteCard('separate', 'new@example.test'), + remoteCard('merge', 'duplicate@example.test'), + remoteCard('skip', 'duplicate@example.test'), + ]; + + const result = await carddavSync.applyBookDelta(client, completePlan({ upserts: cards })); + + expect(result).toMatchObject({ + ledgerChanged: true, + remote: 3, + updated: 2, + removed: 0, + }); + expect(result).not.toHaveProperty('count'); + expect(result).not.toHaveProperty('contactCount'); + expect(result).not.toHaveProperty('skipped'); + expect(result).not.toHaveProperty('merged'); + }); + + it('counts a deleted separately projected contact at the apply boundary', async () => { + const card = remoteCard('gone', 'gone@example.test'); + const client = projectionClient({ + objects: [{ + address_book_id: BOOK_ID, + href: card.href, + remote_etag: card.remoteEtag, + vcard: card.vcard, + primary_email: 'gone@example.test', + local_contact_id: TARGET_ID, + legacy_projection: null, + }], + contacts: [targetContactRow({ + id: TARGET_ID, + address_book_id: BOOK_ID, + uid: 'owned-remote', + primary_email: 'gone@example.test', + })], + }); + + const result = await carddavSync.applyBookDelta(client, completePlan({ + replaceAll: false, + removedHrefs: [card.href], + })); + + expect(result).toMatchObject({ remote: 0, updated: 0, removed: 1 }); + }); +}); + +describe('automatic apply boundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('persists observed capabilities inside the guarded book transaction', async () => { + const plan = completePlan({ + book: { + url: BOOK_URL, + displayName: 'Remote', + discoveryIndex: 0, + capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' }, + }, + }); + const client = { + query: vi.fn(async (sql, params) => { + if (/information_schema\.columns[\s\S]+AS has_legacy_projection/.test(sql)) { + return { rows: [{ + id: USER_ID, + has_legacy_projection: false, + connection_generation: CONNECTION_GENERATION, + contact_count: '0', + }] }; + } + if (/external_url = ANY\(\$2::text\[\]\)/.test(sql)) { + return { rows: [{ + id: BOOK_ID, + external_url: BOOK_URL, + remote_sync_token: null, + remote_sync_revision: '0', + sync_token: 'local-token', + remote_projection_fingerprint: null, + }] }; + } + if (/UPDATE address_books SET/.test(sql)) { + expect(sql).toMatch(/remote_create_capability = \$7/); + expect(sql).toMatch(/remote_update_capability = \$8/); + expect(sql).toMatch(/remote_delete_capability = \$9/); + expect(params.slice(6)).toEqual(['allowed', 'unknown', 'denied']); + return { rows: [], rowCount: 1 }; + } + return { rows: [], rowCount: 1 }; + }), + }; + + await expect(carddavSync.applyBookDelta(client, plan)).resolves.toMatchObject({ + remote: 0, + updated: 0, + removed: 0, + }); + }); + + it('refreshes an imported projection when its remote contact changes', async () => { + const card = remoteCard('projected', 'projected@example.test'); + const updatedVcard = card.vcard.replace( + 'FN:projected\r\n', + 'FN:Projected After\r\n', + ); + const updated = { + ...card, + vcard: updatedVcard, + contact: { ...card.contact, displayName: 'Projected After' }, + }; + const localUid = createHash('sha256').update(card.href).digest('hex'); + const beforeVcard = generateVCard({ + ...card.contact, + uid: localUid, + displayName: 'Projected Before', + }); + const client = projectionClient({ + objects: [{ + address_book_id: BOOK_ID, + href: card.href, + remote_etag: card.remoteEtag, + vcard: card.vcard, + primary_email: card.contact.primaryEmail, + local_contact_id: TARGET_ID, + legacy_projection: null, + }], + contacts: [targetContactRow({ + id: TARGET_ID, + address_book_id: BOOK_ID, + address_book_source: 'carddav', + uid: localUid, + vcard: beforeVcard, + etag: createHash('md5').update(beforeVcard).digest('hex'), + display_name: 'Projected Before', + primary_email: card.contact.primaryEmail, + emails: card.contact.emails, + })], + contactCount: 1, + }); + + const result = await carddavSync.applyBookDelta(client, completePlan({ + replaceAll: false, + upserts: [updated], + })); + + const contactUpdate = client.query.mock.calls.find(([sql]) => ( + /UPDATE contacts\s+SET/.test(sql) + )); + expect(contactUpdate).toBeDefined(); + expect(contactUpdate[1]).toEqual(expect.arrayContaining([ + USER_ID, + TARGET_ID, + 'Projected After', + ])); + expect(result.changedBookIds).toEqual([BOOK_ID]); + }); +}); + +describe('prepareBookPlan fences', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('carries connection generation and opaque remote revision into the plan', async () => { + const collectionIdentity = { + observedUrl: BOOK_URL, + canonicalUrl: BOOK_URL, + }; + mocks.query.mockResolvedValue({ + rows: [{ + connection_generation: CONNECTION_GENERATION, + remote_sync_token: 'opaque-before', + remote_sync_capability: 'sync-collection', + remote_sync_revision: '9223372036854775806', + }], + }); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: 'opaque-before', + nextRemoteToken: 'opaque-after', + capability: 'sync-collection', + replaceAll: false, + collectionIdentity, + upserts: [], + removedHrefs: [], + }); + + const plan = await carddavSync.prepareBookPlan( + USER_ID, + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + 'separate', + { username: 'user', password: 'password' }, + ); + + expect(plan).toMatchObject({ + connectionGeneration: CONNECTION_GENERATION, + expectedRemoteRevision: '9223372036854775806', + expectedRemoteToken: 'opaque-before', + collectionIdentity, + }); + expect(mocks.query).toHaveBeenCalledOnce(); + expect(mocks.query.mock.calls[0][0]).toMatch(/connectionGeneration/); + expect(mocks.query.mock.calls[0][0]).toMatch(/remote_sync_revision/); + expect(mocks.query.mock.calls.some(([sql]) => /UPDATE address_books/.test(sql))).toBe(false); + }); + + it('carries the complete discovery book without duplicate-mode state', async () => { + const book = { + url: BOOK_URL, + displayName: 'Remote', + supportsSyncCollection: true, + discoveryIndex: 2, + capabilities: { create: 'allowed', update: 'unknown', delete: 'denied' }, + addressData: [{ contentType: 'text/vcard', version: '4.0' }], + }; + mocks.query.mockResolvedValue({ rows: [{ + connection_generation: CONNECTION_GENERATION, + remote_sync_token: 'before', + remote_sync_capability: 'sync-collection', + remote_sync_revision: '7', + }] }); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: 'before', + nextRemoteToken: 'after', + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { observedUrl: BOOK_URL, canonicalUrl: BOOK_URL }, + upserts: [], + removedHrefs: [], + }); + + const plan = await carddavSync.prepareBookPlan( + USER_ID, + book, + { username: 'user', password: 'password' }, + ); + + expect(plan).toMatchObject({ + book, + connectionGeneration: CONNECTION_GENERATION, + expectedRemoteRevision: '7', + expectedRemoteToken: 'before', + }); + expect(plan).not.toHaveProperty('dupMode'); + }); + + it('carries required generation and revision zero when the remote book is absent', async () => { + mocks.query.mockResolvedValue({ + rows: [{ + connection_generation: CONNECTION_GENERATION, + book_id: null, + remote_sync_token: null, + remote_sync_capability: null, + remote_sync_revision: null, + }], + }); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: null, + nextRemoteToken: null, + capability: 'snapshot', + replaceAll: true, + collectionIdentity: { observedUrl: BOOK_URL, canonicalUrl: BOOK_URL }, + upserts: [], + removedHrefs: [], + }); + + const plan = await carddavSync.prepareBookPlan( + USER_ID, + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + 'separate', + { username: 'user', password: 'password' }, + ); + + expect(plan).toMatchObject({ + connectionGeneration: CONNECTION_GENERATION, + expectedRemoteRevision: '0', + expectedRemoteToken: null, + }); + expect(mocks.query.mock.calls[0][0]).toMatch( + /FROM user_integrations ui[\s\S]+LEFT JOIN address_books/, + ); + }); + + it('replaces an alias delta with one full canonical reconciliation', async () => { + const discarded = remoteCard('discarded', 'discarded@example.test'); + const retained = remoteCard('retained', 'retained@example.test'); + mocks.query.mockResolvedValue({ + rows: [{ + connection_generation: CONNECTION_GENERATION, + remote_sync_token: 'alias-token-before', + remote_sync_capability: 'sync-collection', + remote_sync_revision: '7', + }], + }); + mocks.fetchAddressBookDelta + .mockResolvedValueOnce({ + expectedRemoteToken: 'alias-token-before', + nextRemoteToken: 'discarded-token', + capability: 'sync-collection', + replaceAll: false, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + upserts: [{ + href: discarded.href, + etag: discarded.remoteEtag, + vcard: discarded.vcard, + }], + removedHrefs: ['discarded.vcf'], + }) + .mockResolvedValueOnce({ + expectedRemoteToken: null, + nextRemoteToken: 'canonical-token-after', + capability: 'sync-collection', + replaceAll: true, + collectionIdentity: { + observedUrl: CANONICAL_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + upserts: [{ href: retained.href, etag: retained.remoteEtag, vcard: retained.vcard }], + removedHrefs: [], + }); + + const plan = await carddavSync.prepareBookPlan( + USER_ID, + { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + 'separate', + { username: 'user', password: 'password' }, + ); + + expect(mocks.fetchAddressBookDelta.mock.calls).toEqual([ + [expect.objectContaining({ url: ALIAS_BOOK_URL, syncToken: 'alias-token-before' })], + [expect.objectContaining({ url: CANONICAL_BOOK_URL, syncToken: '' })], + ]); + expect(plan).toMatchObject({ + connectionGeneration: CONNECTION_GENERATION, + expectedRemoteRevision: '7', + expectedRemoteToken: 'alias-token-before', + nextRemoteToken: 'canonical-token-after', + replaceAll: true, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + removedHrefs: [], + }); + expect(plan.upserts.map(card => card.href)).toEqual([retained.href]); + }); + + it('does not refetch an alias plan that is already a full reconciliation', async () => { + mocks.query.mockResolvedValue({ rows: [{ + book_id: null, + remote_sync_token: null, + remote_sync_capability: null, + remote_sync_revision: null, + connection_generation: CONNECTION_GENERATION, + }] }); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: null, + nextRemoteToken: 'canonical-token-after', + capability: 'snapshot', + replaceAll: true, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + upserts: [], + removedHrefs: [], + }); + + await carddavSync.prepareBookPlan( + USER_ID, + { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + 'separate', + { username: 'user', password: 'password' }, + ); + + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce(); + }); + + it('rejects a canonical reconciliation that finishes at another identity', async () => { + const changedUrl = 'https://dav.example.test/addressbooks/changed-again/'; + mocks.query.mockResolvedValue({ rows: [{ + remote_sync_token: 'alias-token-before', + remote_sync_capability: 'sync-collection', + remote_sync_revision: '7', + connection_generation: CONNECTION_GENERATION, + }] }); + mocks.fetchAddressBookDelta + .mockResolvedValueOnce({ + expectedRemoteToken: 'alias-token-before', nextRemoteToken: 'discarded', + capability: 'sync-collection', replaceAll: false, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + upserts: [], removedHrefs: [], + }) + .mockResolvedValueOnce({ + expectedRemoteToken: null, nextRemoteToken: 'changed', + capability: 'sync-collection', replaceAll: true, + collectionIdentity: { observedUrl: CANONICAL_BOOK_URL, canonicalUrl: changedUrl }, + upserts: [], removedHrefs: [], + }); + + const error = await carddavSync.prepareBookPlan( + USER_ID, + { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + 'separate', + { username: 'user', password: 'password' }, + ).catch(caught => caught); + + expect(error).toBeInstanceOf(carddavSync.StaleCarddavPlanError); + expect(error).toMatchObject({ reason: 'canonical-reconciliation-required' }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2); + expect(mocks.withTransaction).not.toHaveBeenCalled(); + }); +}); + +describe('pull-first automatic export orchestration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches and applies every book before exporting with the same snapshot', async () => { + const books = [ + { + url: BOOK_URL, + displayName: 'Remote', + supportsSyncCollection: true, + discoveryIndex: 0, + capabilities: { create: 'allowed', update: 'allowed', delete: 'allowed' }, + }, + { + url: `${BOOK_URL}team/`, + displayName: 'Team', + supportsSyncCollection: true, + discoveryIndex: 1, + capabilities: { create: 'unknown', update: 'unknown', delete: 'unknown' }, + }, + ]; + mocks.query.mockImplementation(async (sql) => { + if (/SELECT config FROM user_integrations/.test(sql)) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + connectionGeneration: CONNECTION_GENERATION, + } }] }; + } + if (/SELECT remote_sync_token/.test(sql)) { + return { rows: [{ + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: '0', + connection_generation: CONNECTION_GENERATION, + }] }; + } + if (/mapping.local_contact_id = c.id/.test(sql)) { + return { rows: [{ id: 'local-a' }, { id: 'local-b' }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue(books); + mocks.fetchAddressBookDelta.mockImplementation(async ({ url }) => ({ + expectedRemoteToken: null, + nextRemoteToken: `${url}token`, + capability: 'sync-collection', + replaceAll: true, + collectionIdentity: { observedUrl: url, canonicalUrl: url }, + upserts: [], + removedHrefs: [], + })); + mocks.withTransaction + .mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 }) + .mockResolvedValueOnce({ remote: 0, updated: 0, removed: 0, skipped: 0, merged: 0 }) + .mockResolvedValueOnce(0); + mocks.exportExistingContact + .mockRejectedValueOnce(Object.assign(new Error('read only'), { + code: 'ERR_CARDDAV_READ_ONLY', + })) + .mockResolvedValueOnce({ id: 'local-b' }); + + const result = await carddavSync.syncUser(USER_ID); + + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2); + expect(mocks.exportExistingContact.mock.calls).toEqual([ + [USER_ID, 'local-a', { books, expectedGeneration: CONNECTION_GENERATION }], + [USER_ID, 'local-b', { books, expectedGeneration: CONNECTION_GENERATION }], + ]); + expect(mocks.fetchAddressBookDelta.mock.invocationCallOrder[1]) + .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[0]); + expect(mocks.withTransaction.mock.invocationCallOrder[1]) + .toBeLessThan(mocks.exportExistingContact.mock.invocationCallOrder[0]); + expect(mocks.exportExistingContact.mock.invocationCallOrder[1]) + .toBeLessThan(mocks.withTransaction.mock.invocationCallOrder[2]); + expect(result).toMatchObject({ + ok: true, + exportFailures: [{ + localContactId: 'local-a', + code: 'ERR_CARDDAV_READ_ONLY', + message: 'read only', + }], + }); + }); +}); + +describe('network planning orchestration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('runs read-only pending-intent recovery before planning the scheduled pull', async () => { + configureOneBookSync(); + const client = applyClient(); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: null, + nextRemoteToken: null, + capability: 'sync-collection', + replaceAll: false, + upserts: [], + removedHrefs: [], + }); + mocks.withTransaction + .mockImplementationOnce(callback => callback(client)) + .mockResolvedValueOnce(0); + + await expect(carddavSync.syncUser(USER_ID)).resolves.toMatchObject({ ok: true }); + + expect(mocks.recoverPendingCarddavMutations).toHaveBeenCalledOnce(); + expect(mocks.recoverPendingCarddavMutations.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.fetchAddressBookDelta.mock.invocationCallOrder[0]); + }); + + it('settles every book fetch and vCard parse before opening the first transaction', async () => { + let discoverySettled = false; + const fetchedBooks = []; + let insideTransaction = false; + const client = applyClient(); + const secondBookUrl = 'https://dav.example.test/addressbooks/team/'; + const cards = [ + remoteCard('alpha', 'alpha@example.test'), + remoteCard('beta', 'beta@example.test'), + ].map(card => ({ href: card.href, etag: card.remoteEtag, vcard: card.vcard })); + + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { + rows: [{ + config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + connectionGeneration: CONNECTION_GENERATION, + }, + }], + }; + } + if (sql.includes('SELECT remote_sync_token')) { + return { rows: [{ + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: '0', + connection_generation: CONNECTION_GENERATION, + }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockImplementation(async () => { + expect(insideTransaction).toBe(false); + discoverySettled = true; + return [ + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true }, + ]; + }); + mocks.fetchAddressBookDelta.mockImplementation(async ({ url, syncToken, supportsSyncCollection }) => { + expect(insideTransaction).toBe(false); + expect(syncToken).toBeNull(); + expect(supportsSyncCollection).toBe(true); + const index = url === BOOK_URL ? 0 : 1; + fetchedBooks.push(index); + return { + expectedRemoteToken: null, + nextRemoteToken: `remote-token-${index}`, + capability: 'sync-collection', + replaceAll: true, + upserts: [cards[index]], + removedHrefs: [], + }; + }); + mocks.withTransaction.mockImplementation(async callback => { + expect(discoverySettled).toBe(true); + expect(fetchedBooks).toEqual([0, 1]); + expect(mocks.parseVCard.mock.calls.map(([vcard]) => vcard)).toEqual([ + cards[0].vcard, + cards[1].vcard, + ]); + insideTransaction = true; + try { + return await callback(client); + } finally { + insideTransaction = false; + } + }); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result.error).toBeUndefined(); + expect(result).toMatchObject({ + ok: true, + bookCount: 2, + contactCount: 2, + remote: 2, + fetched: 2, + updated: 2, + removed: 0, + fallback: 0, + }); + expect(mocks.withTransaction).toHaveBeenCalledTimes(3); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2); + expect(mocks.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT remote_sync_token'), + [USER_ID, BOOK_URL], + ); + expect(mocks.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT remote_sync_token'), + [USER_ID, secondBookUrl], + ); + expect(mocks.query.mock.calls.some(([sql]) => ( + /DELETE FROM address_books/.test(sql) + ))).toBe(false); + }); + + it('finalizes a replaced alias under its canonical collection URL', async () => { + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + connectionGeneration: CONNECTION_GENERATION, + } }] }; + } + if (sql.includes('SELECT remote_sync_token')) return { rows: [{ + remote_sync_token: null, + remote_sync_capability: 'unknown', + remote_sync_revision: '0', + connection_generation: CONNECTION_GENERATION, + }] }; + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([ + { url: ALIAS_BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + ]); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: null, + nextRemoteToken: 'canonical-token-after', + capability: 'sync-collection', + replaceAll: true, + collectionIdentity: { + observedUrl: ALIAS_BOOK_URL, + canonicalUrl: CANONICAL_BOOK_URL, + }, + upserts: [], + removedHrefs: [], + }); + const apply = applyClient(); + const finalize = applyClient({ + lifecycleBooks: [{ + id: BOOK_ID, + source: 'carddav', + external_url: CANONICAL_BOOK_URL, + sync_token: 'local-token-before', + }], + }); + mocks.withTransaction + .mockImplementationOnce(callback => callback(apply)) + .mockImplementationOnce(callback => callback(finalize)); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toMatchObject({ ok: true, bookCount: 1 }); + expect(finalize.query.mock.calls.some(([sql]) => ( + /DELETE FROM address_books/.test(sql) + ))).toBe(false); + }); + + it('does not prune when discovery fails before returning a complete book list', async () => { + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted', + } }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockRejectedValue(new Error('discovery incomplete')); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toEqual({ ok: false, error: 'discovery incomplete', ...EMPTY_COUNTERS }); + expect(mocks.fetchAddressBookDelta).not.toHaveBeenCalled(); + expect(mocks.withTransaction).not.toHaveBeenCalled(); + expect(mocks.query.mock.calls.some(([sql]) => sql.includes('DELETE FROM address_books'))).toBe(false); + }); + + it('prunes stale books after a complete empty discovery through finalization', async () => { + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted', + } }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([]); + mocks.withTransaction.mockImplementation(callback => callback(applyClient())); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toMatchObject({ ok: true, bookCount: 0, contactCount: 0 }); + expect(mocks.fetchAddressBookDelta).not.toHaveBeenCalled(); + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + expect(mocks.query.mock.calls.some(([sql]) => ( + /DELETE FROM address_books/.test(sql) + ))).toBe(false); + }); + + it('reports not connected when the integration vanishes before finalization', async () => { + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + connectionGeneration: CONNECTION_GENERATION, + } }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([]); + const finalize = applyClient({ integrationPresent: false }); + mocks.withTransaction.mockImplementation(callback => callback(finalize)); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toEqual({ ok: false, error: 'not connected', ...EMPTY_COUNTERS }); + expect(finalize.query).toHaveBeenCalledOnce(); + expect(finalize.query.mock.calls[0][0]).toMatch(/FROM user_integrations[\s\S]+FOR UPDATE/); + expect(mocks.query).toHaveBeenCalledTimes(2); + expect(mocks.query.mock.calls[1][0]).toMatch(/c\.is_auto = false/); + }); + + it('does not open a transaction or write when a later book multiget batch fails', async () => { + const secondBookUrl = 'https://dav.example.test/addressbooks/team/'; + const client = applyClient(); + const raw = remoteCard('alpha', 'alpha@example.test'); + + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { + rows: [{ + config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + }, + }], + }; + } + if (sql.includes('SELECT remote_sync_token')) return { rows: [{ + book_id: null, + remote_sync_token: null, + remote_sync_capability: null, + remote_sync_revision: null, + connection_generation: CONNECTION_GENERATION, + }] }; + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([ + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true }, + ]); + mocks.fetchAddressBookDelta + .mockResolvedValueOnce({ + expectedRemoteToken: null, + nextRemoteToken: 'first-token', + capability: 'sync-collection', + replaceAll: true, + upserts: [{ href: raw.href, etag: raw.remoteEtag, vcard: raw.vcard }], + removedHrefs: [], + }) + .mockRejectedValueOnce(new Error('multiget batch 2 failed')); + mocks.withTransaction.mockImplementation(callback => callback(client)); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toEqual({ ok: false, error: 'multiget batch 2 failed', ...EMPTY_COUNTERS }); + expect(mocks.withTransaction).not.toHaveBeenCalled(); + expect(client.query).not.toHaveBeenCalled(); + expect(mocks.query.mock.calls.some(([sql]) => sql.includes('DELETE FROM address_books'))).toBe(false); + }); + + it('keeps persisted snapshot mode even when discovery later advertises sync support', async () => { + const client = applyClient(); + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { + rows: [{ + config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + }, + }], + }; + } + if (sql.includes('SELECT remote_sync_token')) { + return { rows: [{ + remote_sync_token: null, + remote_sync_capability: 'snapshot', + remote_sync_revision: '0', + connection_generation: CONNECTION_GENERATION, + }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([ + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + ]); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: null, + nextRemoteToken: null, + capability: 'snapshot', + replaceAll: true, + upserts: [], + removedHrefs: [], + }); + mocks.withTransaction.mockImplementation(callback => callback(client)); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toMatchObject({ ok: true, bookCount: 1, fallback: 1 }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledWith(expect.objectContaining({ + url: BOOK_URL, + supportsSyncCollection: false, + })); + }); + + it('performs exactly one empty-token reconciliation after a valid-sync-token error', async () => { + configureOneBookSync({ remoteToken: 'opaque-before' }); + const client = applyClient({ remoteToken: 'opaque-before' }); + mocks.fetchAddressBookDelta + .mockRejectedValueOnce(new CardDavError('Stored sync token is invalid', { + status: 403, + precondition: 'valid-sync-token', + })) + .mockResolvedValueOnce({ + expectedRemoteToken: '', + nextRemoteToken: 'opaque-after', + capability: 'sync-collection', + replaceAll: true, + upserts: [], + removedHrefs: [], + }); + mocks.withTransaction.mockImplementation(callback => callback(client)); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toMatchObject({ ok: true, bookCount: 1, fallback: 1 }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2); + expect(mocks.fetchAddressBookDelta.mock.calls.map(([request]) => request.syncToken)) + .toEqual(['opaque-before', '']); + const updateBook = client.query.mock.calls.find(([sql]) => sql.includes('UPDATE address_books SET')); + expect(updateBook[1][1]).toBe('opaque-after'); + }); + + it('does not recurse when the empty-token reconciliation gets valid-sync-token again', async () => { + configureOneBookSync({ remoteToken: 'opaque-before' }); + const invalidToken = () => new CardDavError('Stored sync token is invalid', { + status: 403, + precondition: 'valid-sync-token', + }); + mocks.fetchAddressBookDelta + .mockRejectedValueOnce(invalidToken()) + .mockRejectedValueOnce(invalidToken()); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toEqual({ + ok: false, + error: 'Stored sync token is invalid', + ...EMPTY_COUNTERS, + }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2); + expect(mocks.fetchAddressBookDelta.mock.calls.map(([request]) => request.syncToken)) + .toEqual(['opaque-before', '']); + expect(mocks.withTransaction).not.toHaveBeenCalled(); + }); + + it('refetches one stale book after rollback without reapplying successful books', async () => { + const secondBookUrl = 'https://dav.example.test/addressbooks/team/'; + const storedTokens = new Map([ + [BOOK_URL, 'first-before'], + [secondBookUrl, 'second-before'], + ]); + let insideTransaction = false; + let secondTokenReads = 0; + mocks.query.mockImplementation(async (sql, params) => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { + rows: [{ config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + } }], + }; + } + if (sql.includes('SELECT remote_sync_token')) { + const url = params[1]; + if (url === secondBookUrl && secondTokenReads++ > 0) storedTokens.set(url, 'second-concurrent'); + return { rows: [{ + remote_sync_token: storedTokens.get(url), + remote_sync_capability: 'sync-collection', + remote_sync_revision: '0', + connection_generation: CONNECTION_GENERATION, + }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([ + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true }, + ]); + mocks.fetchAddressBookDelta.mockImplementation(async request => { + expect(insideTransaction).toBe(false); + return { + expectedRemoteToken: request.syncToken, + nextRemoteToken: `${request.syncToken}-after`, + capability: 'sync-collection', + replaceAll: false, + upserts: [], + removedHrefs: [], + }; + }); + let transactionCount = 0; + mocks.withTransaction.mockImplementation(async callback => { + transactionCount++; + const remoteToken = transactionCount === 2 ? 'second-concurrent' + : transactionCount === 1 ? 'first-before' : 'second-concurrent'; + insideTransaction = true; + try { + return await callback(applyClient({ remoteToken })); + } finally { + insideTransaction = false; + } + }); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toMatchObject({ ok: true, bookCount: 2 }); + expect(mocks.withTransaction).toHaveBeenCalledTimes(4); + expect(mocks.fetchAddressBookDelta.mock.calls.map(([request]) => [request.url, request.syncToken])) + .toEqual([ + [BOOK_URL, 'first-before'], + [secondBookUrl, 'second-before'], + [secondBookUrl, 'second-concurrent'], + ]); + }); + + it('does not refetch a generation-stale plan with captured credentials', async () => { + configureOneBookSync({ remoteToken: 'opaque-before' }); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: 'opaque-before', + nextRemoteToken: 'must-not-apply', + capability: 'sync-collection', + replaceAll: false, + upserts: [], + removedHrefs: [], + }); + mocks.withTransaction.mockImplementation(callback => callback(applyClient({ + remoteToken: 'opaque-before', + connectionGeneration: 'generation-replaced', + }))); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toEqual({ ok: false, error: 'CardDAV sync plan is stale', ...EMPTY_COUNTERS }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce(); + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + }); + + it('bounds a repeated stale token to one fresh refetch', async () => { + let tokenReads = 0; + configureOneBookSync({ remoteToken: 'unused' }); + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted', + } }] }; + } + if (sql.includes('SELECT remote_sync_token')) { + tokenReads++; + return { rows: [{ + remote_sync_token: tokenReads === 1 ? 'opaque-before' : 'opaque-concurrent', + remote_sync_capability: 'sync-collection', + remote_sync_revision: '0', + connection_generation: CONNECTION_GENERATION, + }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.fetchAddressBookDelta.mockImplementation(async request => ({ + expectedRemoteToken: request.syncToken, + nextRemoteToken: `${request.syncToken}-after`, + capability: 'sync-collection', + replaceAll: false, + upserts: [], + removedHrefs: [], + })); + mocks.withTransaction.mockImplementation(callback => callback(applyClient({ + remoteToken: 'always-newer', + }))); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toEqual({ ok: false, error: 'CardDAV sync plan is stale', ...EMPTY_COUNTERS }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledTimes(2); + expect(mocks.withTransaction).toHaveBeenCalledTimes(2); + }); + + it('does not prune when an apply fails after an earlier book committed', async () => { + const secondBookUrl = 'https://dav.example.test/addressbooks/team/'; + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', username: 'user', password: 'encrypted', + } }] }; + } + if (sql.includes('SELECT remote_sync_token')) return { rows: [{ + book_id: null, + remote_sync_token: null, + remote_sync_capability: null, + remote_sync_revision: null, + connection_generation: CONNECTION_GENERATION, + }] }; + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockResolvedValue([ + { url: BOOK_URL, displayName: 'Remote', supportsSyncCollection: true }, + { url: secondBookUrl, displayName: 'Team', supportsSyncCollection: true }, + ]); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: null, + nextRemoteToken: 'opaque-after', + capability: 'sync-collection', + replaceAll: true, + upserts: [], + removedHrefs: [], + }); + mocks.withTransaction + .mockImplementationOnce(callback => callback(applyClient())) + .mockRejectedValueOnce(new Error('second apply failed')); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toEqual({ ok: false, error: 'second apply failed', ...EMPTY_COUNTERS }); + expect(mocks.withTransaction).toHaveBeenCalledTimes(2); + expect(mocks.query.mock.calls.some(([sql]) => sql.includes('DELETE FROM address_books'))).toBe(false); + }); + + it('retries a changed projection footprint once without refetching the remote plan', async () => { + configureOneBookSync(); + mocks.fetchAddressBookDelta.mockResolvedValue({ + expectedRemoteToken: null, + nextRemoteToken: 'footprint-token', + capability: 'sync-collection', + replaceAll: true, + upserts: [], + removedHrefs: [], + }); + mocks.withTransaction + .mockRejectedValueOnce(new carddavSync.StaleCarddavPlanError({ + reason: 'projection-footprint-changed', + })) + .mockImplementationOnce(callback => callback(applyClient())) + .mockImplementationOnce(callback => callback(applyClient({ lifecycleBooks: [] }))); + + const result = await carddavSync.syncUser(USER_ID); + + expect(result).toMatchObject({ ok: true, bookCount: 1 }); + expect(mocks.fetchAddressBookDelta).toHaveBeenCalledOnce(); + expect(mocks.withTransaction).toHaveBeenCalledTimes(3); + }); + + it('runs the latest committed replacement despite delayed queue request order', async () => { + let currentGeneration = 'generation-a'; + const firstStarted = deferred(); + const releaseFirst = deferred(); + const latestStarted = deferred(); + const latestFinished = deferred(); + let activeDiscoveries = 0; + let maxActiveDiscoveries = 0; + + mocks.query.mockImplementation(async (sql, params) => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: currentGeneration, + connectionGeneration: currentGeneration, + } }] }; + } + if (/UPDATE user_integrations/.test(sql)) { + return { rows: [], rowCount: params[2] === currentGeneration ? 1 : 0 }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockImplementation(async ({ password }) => { + activeDiscoveries++; + maxActiveDiscoveries = Math.max(maxActiveDiscoveries, activeDiscoveries); + try { + if (password === 'generation-a') { + firstStarted.resolve(); + await releaseFirst.promise; + } else if (password === 'generation-c') { + latestStarted.resolve(); + } + return []; + } finally { + activeDiscoveries--; + } + }); + mocks.withTransaction.mockImplementation(async callback => { + const result = await callback(applyClient({ + connectionGeneration: currentGeneration, + lifecycleBooks: [], + })); + if (currentGeneration === 'generation-c') latestFinished.resolve(); + return result; + }); + + expect(carddavSync.requestCarddavSync(USER_ID, 'generation-a')).toBe(true); + await firstStarted.promise; + currentGeneration = 'generation-c'; + expect(carddavSync.requestCarddavSync(USER_ID, 'generation-c')).toBe(false); + expect(carddavSync.requestCarddavSync(USER_ID, 'generation-b')).toBe(false); + expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce(); + + releaseFirst.resolve(); + const launchedLatest = await Promise.race([ + latestStarted.promise.then(() => true), + new Promise(resolve => setImmediate(() => resolve(false))), + ]); + expect(launchedLatest).toBe(true); + await latestFinished.promise; + + expect(mocks.discoverAddressBooks.mock.calls.map(([request]) => request.password)) + .toEqual(['generation-a', 'generation-c']); + expect(maxActiveDiscoveries).toBe(1); + }); + + it('does not queue manual or same-generation overlaps', async () => { + const started = deferred(); + const release = deferred(); + const finished = deferred(); + mocks.query.mockImplementation(async sql => { + if (sql.includes("provider = 'carddav'") && sql.includes('SELECT config')) { + return { rows: [{ config: { + serverUrl: 'https://dav.example.test/', + username: 'user', + password: 'encrypted', + connectionGeneration: CONNECTION_GENERATION, + } }] }; + } + return { rows: [], rowCount: 0 }; + }); + mocks.discoverAddressBooks.mockImplementation(async () => { + started.resolve(); + await release.promise; + return []; + }); + mocks.withTransaction.mockImplementation(async callback => { + const result = await callback(applyClient({ + connectionGeneration: CONNECTION_GENERATION, + lifecycleBooks: [], + })); + finished.resolve(); + return result; + }); + + expect(carddavSync.requestCarddavSync(USER_ID, CONNECTION_GENERATION)).toBe(true); + await started.promise; + await expect(carddavSync.syncUser(USER_ID)).resolves.toMatchObject({ + ok: false, + error: 'A sync is already in progress', + }); + expect(carddavSync.requestCarddavSync(USER_ID, CONNECTION_GENERATION)).toBe(false); + release.resolve(); + await finished.promise; + await new Promise(resolve => setImmediate(resolve)); + + expect(mocks.discoverAddressBooks).toHaveBeenCalledOnce(); + }); +}); + +describe('CardDAV scheduler maintenance', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('cleans resolved conflicts older than a fixed 30 days after fake time advances', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-12T12:00:00.000Z')); + const client = { query: vi.fn() }; + mocks.query.mockResolvedValueOnce({ rows: [] }); + mocks.withTransaction.mockImplementation(callback => callback(client)); + mocks.deleteResolvedConflictsBefore.mockResolvedValue(2); + + await carddavSync.startCardavScheduler(); + + expect(mocks.deleteResolvedConflictsBefore).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); + + expect(mocks.withTransaction).toHaveBeenCalledOnce(); + expect(mocks.deleteResolvedConflictsBefore).toHaveBeenCalledWith( + client, + new Date('2026-06-13T12:00:00.000Z'), + ); + }); +}); diff --git a/backend/src/services/carddavTransport.js b/backend/src/services/carddavTransport.js new file mode 100644 index 00000000..92a6dfe0 --- /dev/null +++ b/backend/src/services/carddavTransport.js @@ -0,0 +1,335 @@ +import { DAV_MAX_RESPONSE_BYTES, parseDavErrorPrecondition } from './carddavXml.js'; +import { getConnectionPolicy } from './connectionPolicy.js'; +import { decrypt } from './encryption.js'; +import { validateHost } from './hostValidation.js'; +import { safeFetch } from './safeFetch.js'; + +const DAV_REQUEST_TIMEOUT_MS = 30_000; +const DAV_OPERATION_TIMEOUT_MS = 300_000; +const DAV_MAX_OPERATION_BYTES = 128 * 1024 * 1024; + +const productionLimits = Object.freeze({ + maxOperationBytes: DAV_MAX_OPERATION_BYTES, + maxResponseBytes: DAV_MAX_RESPONSE_BYTES, + operationTimeoutMs: DAV_OPERATION_TIMEOUT_MS, + requestTimeoutMs: DAV_REQUEST_TIMEOUT_MS, +}); +const operationStates = new WeakMap(); + +export class CardDavError extends Error { + constructor(message, { + status, + requestStatus, + precondition, + operation, + retryAfterAt, + cause, + } = {}) { + super(message, { cause }); + this.name = 'CardDavError'; + this.status = status ?? null; + this.requestStatus = requestStatus ?? null; + this.precondition = precondition ?? null; + this.operation = operation ?? null; + this.retryAfterAt = retryAfterAt ?? null; + } +} + +export function activeRetryAfterAt(source) { + const retryAfterAt = source?.retryAfterAt; + return typeof retryAfterAt === 'string' && Date.parse(retryAfterAt) > Date.now() + ? retryAfterAt + : null; +} + +export async function resolveCarddavCredentials(config) { + const policy = await getConnectionPolicy(); + return { + username: config?.username, + password: decrypt(config?.password), + allowPrivate: policy.allowPrivateHosts, + }; +} + +export function createDavOperation(credentialOrigin) { + return createDavOperationWithLimits(credentialOrigin, productionLimits); +} + +export function testOnlyCreateDavOperation(credentialOrigin, testLimits) { + return createDavOperationWithLimits(credentialOrigin, { + ...productionLimits, + ...(testLimits || {}), + }); +} + +function createDavOperationWithLimits(credentialOrigin, limits) { + let origin; + try { origin = new URL(credentialOrigin).origin; } + catch { throw new Error('Invalid server URL'); } + + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(new DOMException('CardDAV operation timed out', 'TimeoutError')); + }, limits.operationTimeoutMs); + timer.unref?.(); + + let closed = false; + const operation = { + credentialOrigin: origin, + signal: controller.signal, + close() { + if (closed) return; + closed = true; + clearTimeout(timer); + }, + async run(callback) { + try { + return await callback(); + } finally { + operation.close(); + } + }, + }; + operationStates.set(operation, { + controller, + limits, + remainingBytes: limits.maxOperationBytes, + }); + return Object.freeze(operation); +} + +export async function davRequest(operation, method, url, { + username, + password, + depth, + body, + headers: callerHeaders, + acceptedStatuses = [], + errorOperation, + validateRedirect, + allowPrivate = false, +} = {}) { + const state = operationStates.get(operation); + if (!state) throw new TypeError('davRequest requires a DAV operation'); + + const headers = requestHeaders(callerHeaders, body); + headers.Authorization = basicAuth(username, password); + if (depth != null) headers.Depth = String(depth); + + const requestController = new AbortController(); + const requestTimer = setTimeout(() => { + requestController.abort(new DOMException('CardDAV request timed out', 'TimeoutError')); + }, state.limits.requestTimeoutMs); + requestTimer.unref?.(); + const signal = AbortSignal.any([operation.signal, requestController.signal]); + + let response; + let bodyText; + let requestUrl; + try { + const parsedUrl = await abortable(assertHostAllowed(url, allowPrivate), signal); + if (parsedUrl.origin !== operation.credentialOrigin) { + throw errorWithCode( + 'Credentials cannot be sent to a different origin', + 'ERR_CROSS_ORIGIN_REDIRECT', + ); + } + response = await safeFetch(url, { + method, + headers, + body, + signal, + }, { + allowPrivate, + credentialOrigin: operation.credentialOrigin, + ...(validateRedirect ? { validateRedirect } : {}), + }); + requestUrl = response.url || parsedUrl.href; + if (new URL(requestUrl).origin !== operation.credentialOrigin) { + throw errorWithCode( + 'Credentialed redirects must stay on the configured origin', + 'ERR_CROSS_ORIGIN_REDIRECT', + ); + } + bodyText = await readBody(response, state); + } catch (error) { + if (error instanceof CardDavError) throw error; + if (error.name === 'TimeoutError' + || (signal.aborted && signal.reason?.name === 'TimeoutError')) { + throw new CardDavError('CardDAV server did not respond (timed out)', { + operation: errorOperation, + cause: error, + }); + } + throw new CardDavError(`Could not reach the CardDAV server: ${error.message}`, { + operation: errorOperation, + cause: error, + }); + } finally { + clearTimeout(requestTimer); + } + + if (response.status === 401) { + throw new CardDavError('Authentication failed — check the username and app password', { + status: response.status, + requestStatus: response.status, + precondition: parseDavErrorPrecondition(bodyText), + operation: errorOperation, + }); + } + if (!response.ok && response.status !== 207 && !acceptedStatuses.includes(response.status)) { + throw new CardDavError(`CardDAV request failed (${response.status} ${response.statusText})`, { + status: response.status, + requestStatus: response.status, + precondition: parseDavErrorPrecondition(bodyText), + operation: errorOperation, + retryAfterAt: response.status === 429 + ? parseRetryAfter(response.headers.get('retry-after')) + : null, + }); + } + return { + bodyText, + headers: response.headers, + requestUrl, + status: response.status, + }; +} + +// Retry-After is remote-controlled: a malicious/compromised server (or a MITM on +// a plaintext-allowed private target) could otherwise return a huge delay and +// freeze sync for an unbounded duration. Cap the honored eligibility delay at one +// hour so throttling stays bounded; past/immediate values pass through unchanged. +const MAX_RETRY_AFTER_MS = 60 * 60 * 1000; + +function parseRetryAfter(value) { + if (value == null) return null; + const cap = Date.now() + MAX_RETRY_AFTER_MS; + if (/^\d+$/.test(value)) { + const timestamp = BigInt(Date.now()) + BigInt(value) * 1000n; + if (timestamp > BigInt(cap)) return new Date(cap).toISOString(); + return new Date(Number(timestamp)).toISOString(); + } + if (!/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?:0[1-9]|[12]\d|3[01]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} (?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d GMT$/.test(value)) { + return null; + } + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp) || new Date(timestamp).toUTCString() !== value) return null; + return new Date(Math.min(timestamp, cap)).toISOString(); +} + +function requestHeaders(callerHeaders, body) { + const requested = new Headers(callerHeaders); + const headers = {}; + for (const [name, value] of requested) { + if (name === 'authorization' || name === 'depth') continue; + headers[name] = value; + } + if (body != null && !requested.has('content-type')) { + headers['Content-Type'] = 'application/xml; charset=utf-8'; + } + return headers; +} + +function basicAuth(username, password) { + return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); +} + +async function assertHostAllowed(url, allowPrivate) { + let parsed; + try { parsed = new URL(url); } + catch { throw new Error('Invalid server URL'); } + const error = await validateHost(parsed.hostname, { allowPrivate }); + if (error) throw new Error(error); + return parsed; +} + +function abortable(promise, signal) { + if (signal.aborted) return Promise.reject(signal.reason); + let onAbort; + const aborted = new Promise((resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + }); + return Promise.race([promise, aborted]) + .finally(() => signal.removeEventListener('abort', onAbort)); +} + +async function readBody(response, state) { + const declaredLengthError = contentLengthError(response, state); + if (!response.body) { + if (declaredLengthError) throw declaredLengthError; + return ''; + } + const reader = response.body.getReader(); + try { + if (declaredLengthError) { + await cancelReader(reader); + throw declaredLengthError; + } + + const decoder = new TextDecoder(); + let responseBytes = 0; + let text = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunkBytes = value.byteLength; + if (responseBytes + chunkBytes > state.limits.maxResponseBytes) { + await cancelReader(reader); + throw errorWithCode( + 'CardDAV response exceeded the response byte limit', + 'ERR_DAV_RESPONSE_TOO_LARGE', + ); + } + if (chunkBytes > state.remainingBytes) { + await cancelReader(reader); + throw errorWithCode( + 'CardDAV operation exceeded the cumulative response byte limit', + 'ERR_DAV_OPERATION_TOO_LARGE', + ); + } + responseBytes += chunkBytes; + state.remainingBytes -= chunkBytes; + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } finally { + reader.releaseLock?.(); + } +} + +function contentLengthError(response, state) { + const declaredLength = parseContentLength(response.headers?.get('content-length')); + if (declaredLength == null) return null; + if (declaredLength > BigInt(state.limits.maxResponseBytes)) { + return errorWithCode( + 'CardDAV response exceeded the response byte limit', + 'ERR_DAV_RESPONSE_TOO_LARGE', + ); + } + if (declaredLength > BigInt(state.remainingBytes)) { + return errorWithCode( + 'CardDAV operation exceeded the cumulative response byte limit', + 'ERR_DAV_OPERATION_TOO_LARGE', + ); + } + return null; +} + +function parseContentLength(value) { + if (value == null || !/^\d+$/.test(value)) return null; + return BigInt(value); +} + +async function cancelReader(reader) { + try { + await reader.cancel(); + } catch { + // The request is already failing; cancellation is best-effort. + } +} + +function errorWithCode(message, code) { + return Object.assign(new Error(message), { code }); +} diff --git a/backend/src/services/carddavTransport.test.js b/backend/src/services/carddavTransport.test.js new file mode 100644 index 00000000..a59deec8 --- /dev/null +++ b/backend/src/services/carddavTransport.test.js @@ -0,0 +1,491 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateHost } from './hostValidation.js'; +import { safeFetch } from './safeFetch.js'; +import { + CardDavError, + createDavOperation, + davRequest, + testOnlyCreateDavOperation, +} from './carddavTransport.js'; + +vi.mock('./hostValidation.js', () => ({ + validateHost: vi.fn().mockResolvedValue(null), +})); + +vi.mock('./safeFetch.js', () => ({ + safeFetch: vi.fn(), +})); + +const ORIGIN = 'https://dav.example.test'; +const REQUEST_URL = `${ORIGIN}/addressbooks/user/contacts/`; + +function limits(overrides = {}) { + return { + maxOperationBytes: 64, + maxResponseBytes: 32, + operationTimeoutMs: 1_000, + requestTimeoutMs: 500, + ...overrides, + }; +} + +function fakeResponse({ + chunks = [], + contentLength, + hasBody = true, + headers: responseHeaders, + ok = true, + status = 207, + statusText = 'Multi-Status', + url = REQUEST_URL, +} = {}) { + let index = 0; + const read = vi.fn(async () => ( + index < chunks.length + ? { done: false, value: chunks[index++] } + : { done: true, value: undefined } + )); + const cancel = vi.fn(async () => {}); + const releaseLock = vi.fn(); + const headers = new Headers(responseHeaders); + if (contentLength != null) headers.set('Content-Length', contentLength); + return { + response: { + body: hasBody ? { getReader: () => ({ cancel, read, releaseLock }) } : null, + headers, + ok, + status, + statusText, + url, + }, + cancel, + read, + releaseLock, + }; +} + +function bytes(text) { + return new TextEncoder().encode(text); +} + +function request(operation, overrides = {}) { + return davRequest(operation, 'REPORT', REQUEST_URL, { + body: '', + password: 'test-password', + username: 'test-user', + ...overrides, + }); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('DAV response byte limits', () => { + it('rejects an oversized Content-Length before reading the body', async () => { + const body = fakeResponse({ chunks: [bytes('ignored')], contentLength: '9' }); + safeFetch.mockResolvedValueOnce(body.response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: 8 })); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + cause: { code: 'ERR_DAV_RESPONSE_TOO_LARGE' }, + }); + expect(body.read).not.toHaveBeenCalled(); + expect(body.cancel).toHaveBeenCalledOnce(); + expect(body.releaseLock).toHaveBeenCalledOnce(); + }); + + it('rejects an oversized Content-Length even when the response has no body stream', async () => { + const response = fakeResponse({ contentLength: '9', hasBody: false }); + safeFetch.mockResolvedValueOnce(response.response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: 8 })); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + cause: { code: 'ERR_DAV_RESPONSE_TOO_LARGE' }, + }); + expect(response.read).not.toHaveBeenCalled(); + }); + + it('cancels a chunked UTF-8 body when streamed bytes cross the response cap', async () => { + const body = fakeResponse({ chunks: [bytes('abc'), bytes('de')], contentLength: '2' }); + safeFetch.mockResolvedValueOnce(body.response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: 4 })); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + cause: { code: 'ERR_DAV_RESPONSE_TOO_LARGE' }, + }); + expect(body.read).toHaveBeenCalledTimes(2); + expect(body.cancel).toHaveBeenCalledOnce(); + }); + + it('rejects the second individually valid body when cumulative bytes cross the operation cap', async () => { + const first = fakeResponse({ chunks: [bytes('abc')] }); + const second = fakeResponse({ chunks: [bytes('def')] }); + safeFetch.mockResolvedValueOnce(first.response).mockResolvedValueOnce(second.response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ + maxOperationBytes: 5, + maxResponseBytes: 4, + })); + + await expect(operation.run(async () => { + await request(operation); + return request(operation); + })).rejects.toMatchObject({ + cause: { code: 'ERR_DAV_OPERATION_TOO_LARGE' }, + }); + expect(second.cancel).toHaveBeenCalledOnce(); + }); + + it('accepts exact response and operation boundaries and decodes a split multibyte character', async () => { + const encoded = bytes('A€'); + const body = fakeResponse({ + chunks: [encoded.slice(0, 2), encoded.slice(2)], + contentLength: String(encoded.byteLength), + }); + safeFetch.mockResolvedValueOnce(body.response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ + maxOperationBytes: encoded.byteLength, + maxResponseBytes: encoded.byteLength, + })); + + await expect(operation.run(() => request(operation))).resolves.toMatchObject({ + bodyText: 'A€', + requestUrl: REQUEST_URL, + }); + expect(body.cancel).not.toHaveBeenCalled(); + }); +}); + +describe('DAV operation lifetime', () => { + it('aborts at the operation deadline after the per-request timer is recreated', async () => { + safeFetch + .mockImplementationOnce(() => new Promise(resolve => { + setTimeout(() => resolve(fakeResponse({ chunks: [bytes('ok')] }).response), 15); + })) + .mockImplementationOnce((_url, options) => new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true }); + })); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ + operationTimeoutMs: 20, + requestTimeoutMs: 100, + })); + const pending = operation.run(async () => { + await request(operation); + return request(operation); + }); + const rejected = expect(pending).rejects.toMatchObject({ + name: 'CardDavError', + message: 'CardDAV server did not respond (timed out)', + cause: { name: 'TimeoutError' }, + }); + + await vi.advanceTimersByTimeAsync(20); + + await rejected; + expect(safeFetch).toHaveBeenCalledTimes(2); + expect(safeFetch.mock.calls[1][1].signal.aborted).toBe(true); + }); + + it('aborts never-settling host validation at the operation deadline', async () => { + validateHost.mockImplementationOnce(() => new Promise(() => {})); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ + operationTimeoutMs: 20, + requestTimeoutMs: 100, + })); + let rejection; + operation.run(() => request(operation)).catch(error => { rejection = error; }); + + await vi.advanceTimersByTimeAsync(20); + + expect(rejection).toMatchObject({ + name: 'CardDavError', + message: 'CardDAV server did not respond (timed out)', + cause: { name: 'TimeoutError' }, + }); + expect(safeFetch).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears operation and request timers after completion', async () => { + safeFetch.mockResolvedValueOnce(fakeResponse({ chunks: [bytes('ok')] }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await operation.run(() => request(operation)); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears operation and request timers after failure', async () => { + const cause = new Error('socket closed'); + safeFetch.mockRejectedValueOnce(cause); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + message: 'Could not reach the CardDAV server: socket closed', + cause, + }); + + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('DAV request boundary', () => { + it('exposes delta-seconds Retry-After as an absolute timestamp on 429', async () => { + vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z')); + safeFetch.mockResolvedValueOnce(fakeResponse({ + headers: { 'Retry-After': '120' }, + ok: false, + status: 429, + statusText: 'Too Many Requests', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + status: 429, + retryAfterAt: '2026-07-12T12:02:00.250Z', + }); + }); + + it('exposes an IMF-fixdate Retry-After as its absolute timestamp on 429', async () => { + vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z')); + safeFetch.mockResolvedValueOnce(fakeResponse({ + headers: { 'Retry-After': 'Sun, 12 Jul 2026 12:02:00 GMT' }, + ok: false, + status: 429, + statusText: 'Too Many Requests', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + status: 429, + retryAfterAt: '2026-07-12T12:02:00.000Z', + }); + }); + + it('exposes a valid past IMF-fixdate without turning it into a future delay', async () => { + vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z')); + safeFetch.mockResolvedValueOnce(fakeResponse({ + headers: { 'Retry-After': 'Thu, 01 Jan 1970 00:00:00 GMT' }, + ok: false, + status: 429, + statusText: 'Too Many Requests', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + status: 429, + retryAfterAt: '1970-01-01T00:00:00.000Z', + }); + }); + + it('caps a huge delta-seconds Retry-After at one hour from now', async () => { + vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z')); + safeFetch.mockResolvedValueOnce(fakeResponse({ + headers: { 'Retry-After': '9999999999' }, + ok: false, + status: 429, + statusText: 'Too Many Requests', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + status: 429, + retryAfterAt: '2026-07-12T13:00:00.250Z', + }); + }); + + it('caps a far-future IMF-fixdate Retry-After at one hour from now', async () => { + vi.setSystemTime(new Date('2026-07-12T12:00:00.250Z')); + safeFetch.mockResolvedValueOnce(fakeResponse({ + headers: { 'Retry-After': 'Fri, 01 Jan 2100 00:00:00 GMT' }, + ok: false, + status: 429, + statusText: 'Too Many Requests', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + status: 429, + retryAfterAt: '2026-07-12T13:00:00.250Z', + }); + }); + + it.each([ + ['missing', undefined], + ['arbitrary date text', 'tomorrow'], + ['non-numeric delay', '120 seconds'], + ['invalid IMF-fixdate', 'Sun, 32 Jul 2026 12:02:00 GMT'], + ])('does not expose Retry-After eligibility for a 429 with %s header', async (_label, value) => { + const headers = value === undefined ? {} : { 'Retry-After': value }; + safeFetch.mockResolvedValueOnce(fakeResponse({ + headers, + ok: false, + status: 429, + statusText: 'Too Many Requests', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + status: 429, + retryAfterAt: null, + }); + }); + + it('merges caller headers and exposes a bounded non-XML success body', async () => { + const vcard = 'BEGIN:VCARD\nVERSION:4.0\nEND:VCARD'; + safeFetch.mockResolvedValueOnce(fakeResponse({ + chunks: [bytes(vcard)], + headers: { ETag: 'W/"opaque"', 'Content-Type': 'text/vcard' }, + status: 200, + statusText: 'OK', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ + maxOperationBytes: bytes(vcard).byteLength, + maxResponseBytes: bytes(vcard).byteLength, + })); + + const result = await operation.run(() => davRequest(operation, 'GET', REQUEST_URL, { + headers: { Accept: 'text/vcard', Authorization: 'Basic attacker-controlled' }, + password: 'test-password', + username: 'test-user', + })); + + expect(result).toMatchObject({ + bodyText: vcard, + requestUrl: REQUEST_URL, + status: 200, + }); + expect(result.headers.get('etag')).toBe('W/"opaque"'); + const requestHeaders = new Headers(safeFetch.mock.calls[0][1].headers); + expect(requestHeaders.get('accept')).toBe('text/vcard'); + expect(requestHeaders.get('authorization')) + .toBe(`Basic ${Buffer.from('test-user:test-password').toString('base64')}`); + expect(requestHeaders.has('content-type')).toBe(false); + }); + + it('returns the final trusted response URL for href resolution', async () => { + const finalUrl = `${ORIGIN}/canonical/contacts/`; + safeFetch.mockResolvedValueOnce(fakeResponse({ url: finalUrl }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).resolves.toMatchObject({ + bodyText: '', + requestUrl: finalUrl, + }); + }); + + it('passes the credential origin and Basic authorization through one transport path', async () => { + safeFetch.mockResolvedValueOnce(fakeResponse().response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await operation.run(() => request(operation, { allowPrivate: true, depth: 0 })); + + expect(validateHost).toHaveBeenCalledWith('dav.example.test', { allowPrivate: true }); + expect(safeFetch).toHaveBeenCalledWith( + REQUEST_URL, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from('test-user:test-password').toString('base64')}`, + Depth: '0', + }), + method: 'REPORT', + }), + { allowPrivate: true, credentialOrigin: ORIGIN }, + ); + }); + + it('reads an error body through the same bounded path before parsing its DAV precondition', async () => { + const xml = ''; + safeFetch.mockResolvedValueOnce(fakeResponse({ + chunks: [bytes(xml)], + ok: false, + status: 403, + statusText: 'Forbidden', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits({ maxResponseBytes: bytes(xml).byteLength })); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + message: 'CardDAV request failed (403 Forbidden)', + precondition: 'valid-sync-token', + requestStatus: 403, + status: 403, + }); + }); + + it('preserves the existing 401 message', async () => { + safeFetch.mockResolvedValueOnce(fakeResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }).response); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toMatchObject({ + name: 'CardDavError', + message: 'Authentication failed — check the username and app password', + requestStatus: 401, + status: 401, + }); + }); + + it('keeps one CardDavError class and wraps cross-origin policy failures with their cause', async () => { + const cause = Object.assign(new Error('redirect escaped'), { + code: 'ERR_CROSS_ORIGIN_REDIRECT', + }); + safeFetch.mockRejectedValueOnce(cause); + const operation = testOnlyCreateDavOperation(ORIGIN, limits()); + + await expect(operation.run(() => request(operation))).rejects.toSatisfy(error => ( + error instanceof CardDavError + && error.cause === cause + && error.cause.code === 'ERR_CROSS_ORIGIN_REDIRECT' + )); + }); +}); + +describe('CardDAV client transport adoption', () => { + it('does not let extra arguments alter production operation limits', async () => { + safeFetch.mockResolvedValueOnce(fakeResponse({ chunks: [bytes('ok')] }).response); + const operation = createDavOperation(ORIGIN, limits({ + maxOperationBytes: 1, + maxResponseBytes: 1, + })); + + await expect(operation.run(() => request(operation))).resolves.toMatchObject({ + bodyText: 'ok', + }); + }); + + it('does not re-export transport operations through the client', async () => { + const client = await import('./carddavClient.js'); + + expect(client).not.toHaveProperty('createDavOperation'); + expect(client).not.toHaveProperty('davRequest'); + }); + + it('uses the streamed transport path without a response.text fallback', async () => { + const xml = ''; + safeFetch.mockResolvedValueOnce(fakeResponse({ chunks: [bytes(xml)] }).response); + const { fetchAddressBookCards } = await import('./carddavClient.js'); + + await expect(fetchAddressBookCards({ + url: REQUEST_URL, + username: 'test-user', + password: 'test-password', + })).resolves.toEqual([]); + }); +}); diff --git a/backend/src/services/carddavXml.js b/backend/src/services/carddavXml.js new file mode 100644 index 00000000..8e193510 --- /dev/null +++ b/backend/src/services/carddavXml.js @@ -0,0 +1,273 @@ +import { XMLParser, XMLValidator } from 'fast-xml-parser'; + +export const DAV_NS = 'DAV:'; +export const CARDDAV_NS = 'urn:ietf:params:xml:ns:carddav'; +export const DAV_MAX_RESPONSE_BYTES = 32 * 1024 * 1024; + +const XML_NS = 'http://www.w3.org/XML/1998/namespace'; +const XMLNS_NS = 'http://www.w3.org/2000/xmlns/'; +const DAV_MAX_ENTITY_EXPANSIONS = 1_000_000; + +const parser = new XMLParser({ + preserveOrder: true, + removeNSPrefix: false, + ignoreAttributes: false, + parseTagValue: false, + parseAttributeValue: false, + htmlEntities: true, + trimValues: false, + ignoreDeclaration: true, + ignorePiTags: true, + maxNestedTags: 100, + processEntities: { + enabled: true, + maxEntitySize: 10_000, + maxExpansionDepth: 10, + maxTotalExpansions: DAV_MAX_ENTITY_EXPANSIONS, + maxExpandedLength: DAV_MAX_RESPONSE_BYTES, + maxEntityCount: 100, + }, +}); + +export function xmlEscape(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function parseXmlDocument(xmlText, label = 'XML document') { + if (typeof xmlText !== 'string' || xmlText.length === 0) { + throw new Error(`${label} was not valid XML: document is empty`); + } + if (/])/i.test(xmlText)) { + throw new Error(`${label} must not contain a DOCTYPE`); + } + + const validation = XMLValidator.validate(xmlText); + if (validation !== true) { + throw new Error(`${label} was not valid XML: ${validation.err.msg}`); + } + + let orderedDocument; + try { + orderedDocument = parser.parse(xmlText); + } catch (error) { + throw new Error(`${label} could not be parsed: ${error.message}`, { cause: error }); + } + + const roots = []; + for (const entry of orderedDocument) { + if (!elementQName(entry)) continue; + roots.push(resolveElement(entry, new Map([['xml', XML_NS]]), label)); + } + if (roots.length !== 1) { + throw new Error(`${label} must contain exactly one top-level element`); + } + return roots[0]; +} + +export function parseDavMultistatus(xmlText, label = 'response') { + const root = parseXmlDocument(xmlText, label); + if (!isNamed(root, DAV_NS, 'multistatus')) { + throw new Error(`CardDAV ${label} was not a valid DAV multistatus`); + } + return root; +} + +export function childrenNamed(node, namespaceURI, localName) { + if (!Array.isArray(node?.children)) return []; + return node.children.filter(child => isNamed(child, namespaceURI, localName)); +} + +export function onlyChildNamed(node, namespaceURI, localName, label = describeNode(node)) { + const matches = childrenNamed(node, namespaceURI, localName); + if (matches.length !== 1) { + throw new Error( + `${label} must contain exactly one ${describeName(namespaceURI, localName)} child; found ${matches.length}`, + ); + } + return matches[0]; +} + +export function textOfNode(node) { + return typeof node?.text === 'string' ? node.text : ''; +} + +export function parseDavResponse(responseNode, label = 'DAV response') { + if (!isNamed(responseNode, DAV_NS, 'response')) { + throw new Error(`${label} was not a DAV response element`); + } + + const href = textOfNode(onlyChildNamed(responseNode, DAV_NS, 'href', label)); + const statusNodes = childrenNamed(responseNode, DAV_NS, 'status'); + const propstatNodes = childrenNamed(responseNode, DAV_NS, 'propstat'); + const hasStatus = statusNodes.length > 0; + const hasPropstats = propstatNodes.length > 0; + + if (hasStatus === hasPropstats) { + throw new Error( + `${label} must contain either one DAV status or one or more DAV propstat children, but not both`, + ); + } + + if (hasStatus) { + const statusNode = onlyChildNamed(responseNode, DAV_NS, 'status', label); + return { + href, + status: parseStatusCode(statusNode, label), + propstats: [], + }; + } + + const propstats = propstatNodes.map((propstatNode, index) => { + const propstatLabel = `${label} propstat ${index + 1}`; + const propNode = onlyChildNamed(propstatNode, DAV_NS, 'prop', propstatLabel); + const statusNode = onlyChildNamed(propstatNode, DAV_NS, 'status', propstatLabel); + return { + status: parseStatusCode(statusNode, propstatLabel), + properties: propNode.children, + }; + }); + + return { href, status: null, propstats }; +} + +export function successfulProperties(response) { + if (!Array.isArray(response?.propstats)) { + throw new TypeError('successfulProperties requires a parsed DAV response'); + } + return response.propstats + .filter(({ status }) => status >= 200 && status < 300) + .flatMap(({ properties }) => properties); +} + +export function parseDavErrorPrecondition(xmlText) { + if (!xmlText) return null; + try { + const root = parseXmlDocument(xmlText, 'DAV error response'); + if (!isNamed(root, DAV_NS, 'error')) return null; + return root.children.find(child => isNamed(child, DAV_NS, 'valid-sync-token'))?.localName ?? null; + } catch { + return null; + } +} + +function resolveElement(entry, inheritedNamespaces, label) { + const qName = elementQName(entry); + const namespaces = new Map(inheritedNamespaces); + const attributes = Object.entries(entry[':@'] || {}).map(([rawName, value]) => ({ + name: rawName.startsWith('@_') ? rawName.slice(2) : rawName, + value, + })); + + for (const { name, value } of attributes) { + const declaredPrefix = namespaceDeclarationPrefix(name, label); + if (declaredPrefix === null) continue; + validateNamespaceDeclaration(declaredPrefix, value, label); + namespaces.set(declaredPrefix, value); + } + const resolvedAttributes = []; + for (const { name, value } of attributes) { + if (namespaceDeclarationPrefix(name, label) !== null) continue; + const { prefix, localName: attributeLocalName } = splitQName(name, label); + if (prefix && (!namespaces.has(prefix) || namespaces.get(prefix) === '')) { + throw new Error(`${label} uses unbound namespace prefix "${prefix}"`); + } + resolvedAttributes.push({ + namespaceURI: prefix ? namespaces.get(prefix) : null, + localName: attributeLocalName, + value, + }); + } + + const { prefix, localName } = splitQName(qName, label); + let namespaceURI = null; + if (prefix) { + if (!namespaces.has(prefix) || namespaces.get(prefix) === '') { + throw new Error(`${label} uses unbound namespace prefix "${prefix}"`); + } + namespaceURI = namespaces.get(prefix); + } else if (namespaces.has('') && namespaces.get('') !== '') { + namespaceURI = namespaces.get(''); + } + + const children = []; + let text = ''; + for (const childEntry of entry[qName] || []) { + if (Object.hasOwn(childEntry, '#text')) { + text += childEntry['#text']; + continue; + } + if (elementQName(childEntry)) { + children.push(resolveElement(childEntry, namespaces, label)); + } + } + + return { namespaceURI, localName, attributes: resolvedAttributes, children, text }; +} + +function namespaceDeclarationPrefix(attributeName, label) { + if (attributeName === 'xmlns') return ''; + if (attributeName === 'xmlns:') { + throw new Error(`${label} contains invalid namespace declaration "xmlns:"`); + } + if (attributeName.startsWith('xmlns:')) return attributeName.slice('xmlns:'.length); + return null; +} + +function validateNamespaceDeclaration(prefix, namespaceURI, label) { + if (prefix.includes(':')) { + throw new Error(`${label} contains invalid namespace declaration "xmlns:${prefix}"`); + } + if (prefix === 'xmlns') { + throw new Error(`${label} cannot declare the reserved namespace prefix "xmlns"`); + } + if (prefix === 'xml') { + if (namespaceURI !== XML_NS) { + throw new Error(`${label} cannot rebind the reserved namespace prefix "xml"`); + } + return; + } + if (namespaceURI === XML_NS || namespaceURI === XMLNS_NS) { + throw new Error(`${label} cannot bind a reserved namespace URI to "${prefix}"`); + } + if (prefix && namespaceURI === '') { + throw new Error(`${label} cannot undeclare namespace prefix "${prefix}"`); + } +} + +function elementQName(entry) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const names = Object.keys(entry).filter(name => name !== ':@' && name !== '#text'); + return names.length === 1 ? names[0] : null; +} + +function splitQName(qName, label) { + const parts = qName.split(':'); + if (parts.length === 1) return { prefix: '', localName: qName }; + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`${label} contains invalid qualified name "${qName}"`); + } + return { prefix: parts[0], localName: parts[1] }; +} + +function parseStatusCode(statusNode, label) { + const match = /^HTTP\/\S+\s+(\d{3})(?:\s|$)/i.exec(textOfNode(statusNode)); + if (!match) throw new Error(`${label} must contain a parseable DAV status`); + return Number(match[1]); +} + +function isNamed(node, namespaceURI, localName) { + return node?.namespaceURI === namespaceURI && node?.localName === localName; +} + +function describeNode(node) { + return node ? describeName(node.namespaceURI, node.localName) : 'XML element'; +} + +function describeName(namespaceURI, localName) { + return namespaceURI === DAV_NS ? `DAV ${localName}` : `{${namespaceURI ?? ''}}${localName}`; +} diff --git a/backend/src/services/carddavXml.test.js b/backend/src/services/carddavXml.test.js new file mode 100644 index 00000000..68bf632e --- /dev/null +++ b/backend/src/services/carddavXml.test.js @@ -0,0 +1,298 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CARDDAV_NS, + DAV_NS, + childrenNamed, + onlyChildNamed, + parseDavErrorPrecondition, + parseDavMultistatus, + parseDavResponse, + parseXmlDocument, + successfulProperties, + textOfNode, + xmlEscape, +} from './carddavXml.js'; + +describe('xmlEscape', () => { + it('escapes all five predefined XML entities', () => { + expect(xmlEscape(`&<>"'`)).toBe('&<>"''); + }); +}); + +const XML_NS = 'http://www.w3.org/XML/1998/namespace'; +const XMLNS_NS = 'http://www.w3.org/2000/xmlns/'; + +describe('namespace-aware DAV XML parsing', () => { + it('requires the exact DAV namespace for multistatus', () => { + expect(() => parseDavMultistatus( + '', + 'sync response', + )).toThrow(/DAV multistatus/); + }); + + it.each([ + 'next', + 'next', + ])('accepts DAV elements with any bound prefix spelling', xml => { + const root = parseDavMultistatus(xml, 'sync response'); + + expect(root).toMatchObject({ + namespaceURI: DAV_NS, + localName: 'multistatus', + }); + expect(textOfNode(onlyChildNamed(root, DAV_NS, 'sync-token'))).toBe('next'); + }); + + it('preserves opaque text while decoding standard XML entities', () => { + const root = parseDavMultistatus( + ' 00123&x ', + 'sync response', + ); + + expect(textOfNode(onlyChildNamed(root, DAV_NS, 'sync-token'))).toBe(' 00123&x '); + }); + + it('resolves a nested CardDAV namespace without matching a foreign local name', () => { + const root = parseDavMultistatus(` + /contacts/a&b.vcf + BEGIN:VCARD +FN:A & B +END:VCARD + poison + HTTP/1.1 200 OK +`, 'multiget response'); + const response = parseDavResponse( + onlyChildNamed(root, DAV_NS, 'response'), + 'multiget response member', + ); + const properties = successfulProperties(response); + + expect(response.href).toBe('/contacts/a&b.vcf'); + expect(properties.map(({ namespaceURI, localName }) => [namespaceURI, localName])).toEqual([ + [CARDDAV_NS, 'address-data'], + ['urn:not-carddav', 'address-data'], + ]); + expect(textOfNode(properties[0])).toBe('BEGIN:VCARD\nFN:A & B\nEND:VCARD'); + expect(properties.filter(node => ( + node.namespaceURI === CARDDAV_NS && node.localName === 'address-data' + ))).toHaveLength(1); + }); + + it('preserves ordered namespace-aware attributes on CardDAV elements', () => { + const root = parseXmlDocument(` + + + `, 'supported address data'); + + expect(root.children.map(node => node.attributes)).toEqual([ + [ + { namespaceURI: null, localName: 'content-type', value: 'text/vcard' }, + { namespaceURI: null, localName: 'version', value: '4.0' }, + { namespaceURI: 'urn:extension', localName: 'label', value: 'modern' }, + ], + [ + { namespaceURI: null, localName: 'version', value: '3.0' }, + { namespaceURI: null, localName: 'content-type', value: 'text/x-vcard' }, + ], + ]); + }); + + it('rejects malformed XML, multiple roots, unbound prefixes, and a custom-entity DOCTYPE', () => { + expect(() => parseXmlDocument( + '', + 'sync response', + )).toThrow(/valid XML/); + expect(() => parseXmlDocument( + '', + 'sync response', + )).toThrow(/valid XML|top-level/); + expect(() => parseXmlDocument('', 'sync response')) + .toThrow(/unbound namespace prefix/); + expect(() => parseXmlDocument( + ']>&token;', + 'sync response', + )).toThrow(/DOCTYPE/); + }); + + it('rejects an unbound prefixed attribute QName', () => { + expect(() => parseXmlDocument( + '', + 'DAV error response', + )).toThrow(/unbound namespace prefix "X"/); + }); + + it('rejects a namespace declaration with an empty prefix QName', () => { + expect(() => parseXmlDocument( + '', + 'DAV error response', + )).toThrow(/invalid namespace declaration/); + }); + + it.each([ + { + name: 'xml rebound to DAV', + xml: '', + }, + { + name: 'xmlns declared as a normal prefix', + xml: '', + }, + { + name: 'another prefix bound to the XML namespace', + xml: ``, + }, + { + name: 'a prefix bound to the xmlns namespace', + xml: ``, + }, + ])('rejects reserved namespace binding: $name', ({ xml }) => { + expect(() => parseXmlDocument(xml, 'DAV error response')).toThrow(/reserved namespace/); + }); + + it('accepts the implicit xml prefix and its exact reserved binding', () => { + const root = parseXmlDocument( + ``, + 'DAV error response', + ); + + expect(root).toMatchObject({ namespaceURI: DAV_NS, localName: 'error' }); + }); +}); + +describe('DAV response structure', () => { + it('requires exactly one required direct child', () => { + const root = parseDavMultistatus( + 'onetwo', + 'sync response', + ); + + expect(() => onlyChildNamed(root, DAV_NS, 'sync-token')) + .toThrow(/exactly one.*sync-token/i); + }); + + it('requires one href and response status XOR one or more propstats', () => { + const duplicateHref = responseNode(` + onetwoHTTP/1.1 200 OK + `); + const mixedShape = responseNode(` + oneHTTP/1.1 200 OK + HTTP/1.1 200 OK + `); + const missingShape = responseNode( + 'one', + ); + + expect(() => parseDavResponse(duplicateHref)).toThrow(/exactly one.*href/i); + expect(() => parseDavResponse(mixedShape)).toThrow(/status.*propstat|propstat.*status/i); + expect(() => parseDavResponse(missingShape)).toThrow(/status.*propstat|propstat.*status/i); + }); + + it('requires exactly one prop and one parseable status per propstat', () => { + const missingStatus = responseNode(` + oneetag + `); + const invalidStatus = responseNode(` + onesuccess + `); + + expect(() => parseDavResponse(missingStatus)).toThrow(/exactly one.*status/i); + expect(() => parseDavResponse(invalidStatus)).toThrow(/parseable.*status/i); + }); + + it('returns only ordered properties from successful propstats', () => { + const response = parseDavResponse(responseNode(` + 00123 + 0007extension + HTTP/1.1 200 OK + stale + HTTP/1.1 404 Not Found + `)); + + expect(response.href).toBe(' 00123 '); + expect(response.status).toBeNull(); + expect(response.propstats.map(({ status }) => status)).toEqual([200, 404]); + expect(successfulProperties(response).map(node => ({ + namespaceURI: node.namespaceURI, + localName: node.localName, + text: textOfNode(node), + }))).toEqual([ + { namespaceURI: DAV_NS, localName: 'getetag', text: '0007' }, + { namespaceURI: 'urn:extension', localName: 'getetag', text: 'extension' }, + ]); + }); +}); + +describe('parseDavErrorPrecondition', () => { + it('finds an exact DAV valid-sync-token after another DAV child', () => { + expect(parseDavErrorPrecondition( + '', + )).toBe('valid-sync-token'); + }); + + it.each([ + '', + 'not XML', + 'Forbidden', + '', + '', + ])('returns null for a non-DAV error precondition', xml => { + expect(parseDavErrorPrecondition(xml)).toBeNull(); + }); + + it.each([ + '', + '', + '', + ])('does not accept a namespace-invalid DAV error document', xml => { + expect(parseDavErrorPrecondition(xml)).toBeNull(); + }); + + it('does not accept a malformed default-namespace spoof document', () => { + expect(parseDavErrorPrecondition( + '', + )).toBeNull(); + }); +}); + +describe('CardDAV client XML boundary', () => { + it('does not parse a successful response as a DAV error body', async () => { + vi.resetModules(); + const parseErrorPrecondition = vi.fn(); + vi.doMock('./carddavXml.js', async importOriginal => ({ + ...await importOriginal(), + parseDavErrorPrecondition: parseErrorPrecondition, + })); + vi.doMock('./hostValidation.js', () => ({ + validateHost: vi.fn().mockResolvedValue(null), + })); + vi.doMock('./safeFetch.js', () => ({ + safeFetch: vi.fn().mockResolvedValue(new Response( + '', + { status: 207, statusText: 'Multi-Status' }, + )), + })); + + try { + const { fetchAddressBookCards } = await import('./carddavClient.js'); + + await expect(fetchAddressBookCards({ + url: 'https://carddav.example.test/addressbooks/user/contacts/', + username: 'user', + password: 'app-password', + })).resolves.toEqual([]); + expect(parseErrorPrecondition).not.toHaveBeenCalled(); + } finally { + vi.doUnmock('./carddavXml.js'); + vi.doUnmock('./hostValidation.js'); + vi.doUnmock('./safeFetch.js'); + vi.resetModules(); + } + }); +}); + +function responseNode(xml) { + const root = parseXmlDocument(xml, 'DAV response fixture'); + expect(childrenNamed({ children: [root] }, DAV_NS, 'response')).toEqual([root]); + return root; +} diff --git a/backend/src/services/db.js b/backend/src/services/db.js index 68c6d5db..17078a50 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.js @@ -5,7 +5,7 @@ const { Pool } = pg; export const pool = new Pool({ host: process.env.DB_HOST || 'postgres', - port: 5432, + port: Number(process.env.DB_PORT || 5432), database: process.env.DB_NAME || 'mailflow', user: process.env.DB_USER || 'mailflow', password: process.env.DB_PASSWORD, diff --git a/backend/src/services/hostValidation.js b/backend/src/services/hostValidation.js index 5eb559e6..5a57957f 100644 --- a/backend/src/services/hostValidation.js +++ b/backend/src/services/hostValidation.js @@ -27,28 +27,59 @@ function isPrivateIPv4(ip) { ); } -function isPrivateIPv6(ip) { - const h = ip.toLowerCase(); - if (h === '::1' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80')) return true; - // IPv4-mapped IPv6 (::ffff:x.x.x.x) — check the embedded IPv4 address. - // Without this, ::ffff:127.0.0.1 bypasses the IPv4 private-range checks. - if (h.startsWith('::ffff:')) { - const embedded = h.slice(7); - if (isIPv4(embedded)) return isPrivateIPv4(embedded); +function ipv6ToBigInt(ip) { + let normalized = ip.toLowerCase(); + const lastWord = normalized.slice(normalized.lastIndexOf(':') + 1); + if (isIPv4(lastWord)) { + const value = ipv4ToLong(lastWord); + normalized = `${normalized.slice(0, normalized.lastIndexOf(':') + 1)}` + + `${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`; } - // 6to4 (2002::/16) — embeds an IPv4 address in bits 16-47. - // e.g. 2002:7f00:0001:: wraps 127.0.0.1 and bypasses IPv4 checks without this guard. - const sixToFour = h.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/); - if (sixToFour) { - const hi = parseInt(sixToFour[1].padStart(4, '0'), 16); - const lo = parseInt(sixToFour[2].padStart(4, '0'), 16); - const embedded = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; - if (isPrivateIPv4(embedded)) return true; + + const halves = normalized.split('::'); + if (halves.length > 2) return null; + const left = halves[0] ? halves[0].split(':') : []; + const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []; + const missing = 8 - left.length - right.length; + if (halves.length === 2 && missing < 1) return null; + const words = halves.length === 2 + ? [...left, ...Array(missing).fill('0'), ...right] + : left; + if (words.length !== 8 || words.some(word => !/^[0-9a-f]{1,4}$/.test(word))) return null; + + return words.reduce((value, word) => (value << 16n) | BigInt(`0x${word}`), 0n); +} + +function inIPv6Cidr(ip, base, bits) { + const shift = 128n - BigInt(bits); + return (ip >> shift) === (base >> shift); +} + +const ipv4MappedPrefix = ipv6ToBigInt('::ffff:0:0'); +const privateIPv6Cidrs = [ + ['::', 128], + ['::1', 128], + ['64:ff9b:1::', 48], + ['100::', 64], + ['2001::', 32], // Teredo + ['2001:db8::', 32], // documentation + ['2001:10::', 28], // ORCHID + ['2002::', 16], // 6to4 + ['fc00::', 7], // unique-local + ['fe80::', 10], // link-local + ['ff00::', 8], // multicast +].map(([base, bits]) => [ipv6ToBigInt(base), bits]); + +function isPrivateIPv6(ip) { + const value = ipv6ToBigInt(ip); + if (value == null) return false; + if (inIPv6Cidr(value, ipv4MappedPrefix, 96)) { + const embedded = Number(value & 0xffffffffn); + const ipv4 = `${embedded >>> 24}.${(embedded >>> 16) & 0xff}` + + `.${(embedded >>> 8) & 0xff}.${embedded & 0xff}`; + return isPrivateIPv4(ipv4); } - // Teredo (2001:0000::/32) — reject the entire prefix; Teredo tunnels UDP through NAT - // and can reach private ranges via the embedded server/client address fields. - if (/^2001:0{1,4}:/.test(h)) return true; - return false; + return privateIPv6Cidrs.some(([base, bits]) => inIPv6Cidr(value, base, bits)); } // Synchronous check: literal IPs and reserved hostnames. diff --git a/backend/src/services/hostValidation.test.js b/backend/src/services/hostValidation.test.js index 3577b963..680c6340 100644 --- a/backend/src/services/hostValidation.test.js +++ b/backend/src/services/hostValidation.test.js @@ -12,6 +12,23 @@ vi.mock('dns', () => ({ // Pull the mocked fns for per-test control. const { promises: dns } = await import('dns'); +const publicIPv6 = '2600::1'; +const blockedIPv6 = [ + ['unspecified', '::'], + ['loopback', '::1'], + ['IPv4-mapped loopback in hexadecimal form', '::ffff:7f00:1'], + ['IPv4-mapped loopback in dotted form', '::ffff:127.0.0.1'], + ['local-use translation prefix', '64:ff9b:1::1'], + ['discard-only prefix', '100::1'], + ['documentation prefix', '2001:db8::1'], + ['ORCHID prefix', '2001:10::1'], + ['6to4-wrapped private IPv4', '2002:7f00:1::'], + ['unique-local', 'fc00::1'], + ['link-local', 'fe90::1'], + ['multicast', 'ff02::1'], + ['Teredo', '2001:0:4136:e378:8000:63bf:3fff:fdd2'], +]; + beforeEach(() => { dns.resolve4.mockClear(); dns.resolve6.mockClear(); @@ -31,7 +48,7 @@ describe('validateHostLiteral', () => { }); it('passes a valid public IPv6', () => { - expect(validateHostLiteral('2001:db8::1')).toBeNull(); + expect(validateHostLiteral(publicIPv6)).toBeNull(); }); it('returns null for null/undefined/empty', () => { @@ -69,26 +86,24 @@ describe('validateHostLiteral', () => { expect(validateHostLiteral(ip)).toMatch(/private|reserved/i); }); - // Private IPv6 - it.each([ - ['::1'], // loopback - ['fc00::1'], // ULA - ['fd12:3456::1'], // ULA - ['fe80::1'], // link-local - ['::ffff:127.0.0.1'], // IPv4-mapped loopback - ['::ffff:192.168.1.1'], // IPv4-mapped private - ['::ffff:10.0.0.1'], // IPv4-mapped private - ])('blocks private IPv6 %s', ip => { + it.each(blockedIPv6)('blocks reserved IPv6 %s (%s)', (_name, ip) => { expect(validateHostLiteral(ip)).toMatch(/private|reserved/i); }); + it.each([ + ['::ffff:808:808'], + ['::ffff:8.8.8.8'], + ])('passes public IPv4-mapped IPv6 %s', ip => { + expect(validateHostLiteral(ip)).toBeNull(); + }); + // Bracket-wrapped IPv6 it('blocks private IPv6 in bracket notation', () => { expect(validateHostLiteral('[::1]')).toMatch(/private|reserved/i); }); it('passes public IPv6 in bracket notation', () => { - expect(validateHostLiteral('[2001:db8::1]')).toBeNull(); + expect(validateHostLiteral(`[${publicIPv6}]`)).toBeNull(); }); it('trims whitespace before checking', () => { @@ -115,6 +130,16 @@ describe('validateHost', () => { expect(await validateHost('evil.attacker.com')).toMatch(/private|reserved/i); }); + it.each(blockedIPv6)('blocks a hostname resolving to reserved IPv6 %s (%s)', async (_name, ip) => { + dns.resolve6.mockResolvedValue([ip]); + expect(await validateHost('evil.attacker.com')).toMatch(/private|reserved/i); + }); + + it('passes a hostname resolving to a public IPv6 address', async () => { + dns.resolve6.mockResolvedValue([publicIPv6]); + expect(await validateHost('imap.example.com')).toBeNull(); + }); + it('passes when DNS resolution fails (connection will fail naturally)', async () => { dns.resolve4.mockRejectedValue(new Error('NXDOMAIN')); dns.resolve6.mockRejectedValue(new Error('NXDOMAIN')); @@ -148,8 +173,8 @@ describe('resolveForConnection', () => { }); it('returns the literal IP with no servername for a public IPv6', async () => { - const result = await resolveForConnection('2001:db8::1'); - expect(result.host).toBe('2001:db8::1'); + const result = await resolveForConnection(publicIPv6); + expect(result.host).toBe(publicIPv6); expect(result.servername).toBeNull(); }); diff --git a/backend/src/services/migrations.js b/backend/src/services/migrations.js index 38fc9a20..5dd41dd5 100644 --- a/backend/src/services/migrations.js +++ b/backend/src/services/migrations.js @@ -5,20 +5,20 @@ import { pool } from './db.js'; const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../migrations'); -async function getMigrationFiles() { - const files = (await readdir(MIGRATIONS_DIR)) +async function getMigrationFiles(migrationsDirectory) { + const files = (await readdir(migrationsDirectory)) .filter(f => /^\d{4}_.+\.sql$/.test(f)) .sort(); return Promise.all( files.map(async filename => ({ version: filename.replace(/\.sql$/, ''), - sql: await readFile(join(MIGRATIONS_DIR, filename), 'utf8'), + sql: await readFile(join(migrationsDirectory, filename), 'utf8'), })) ); } -export async function runMigrations() { - const client = await pool.connect(); +export async function runMigrationsWithPool(migrationPool, migrationsDirectory) { + const client = await migrationPool.connect(); try { // Session-level advisory lock: held across individual migration transactions, // unlike pg_advisory_xact_lock which releases at each COMMIT and would let @@ -38,7 +38,7 @@ export async function runMigrations() { const { rows } = await client.query('SELECT version FROM schema_migrations ORDER BY version'); const applied = new Set(rows.map(r => r.version)); - const migrations = await getMigrationFiles(); + const migrations = await getMigrationFiles(migrationsDirectory); let ran = 0; for (const { version, sql } of migrations) { @@ -94,3 +94,7 @@ export async function runMigrations() { client.release(); } } + +export async function runMigrations() { + return runMigrationsWithPool(pool, MIGRATIONS_DIR); +} diff --git a/backend/src/services/postgresTestHelpers.js b/backend/src/services/postgresTestHelpers.js new file mode 100644 index 00000000..d87a2c22 --- /dev/null +++ b/backend/src/services/postgresTestHelpers.js @@ -0,0 +1,162 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export function requireTestDatabaseUrl(description) { + const databaseUrl = process.env.TEST_DATABASE_URL; + if (!databaseUrl) { + throw new Error(`TEST_DATABASE_URL is required for ${description}`); + } + return databaseUrl; +} + +export function quoteIdentifier(identifier) { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export function connectionStringFor(databaseUrl, databaseName) { + const url = new URL(databaseUrl); + url.pathname = `/${databaseName}`; + return url.toString(); +} + +export function postgresTestContext(description) { + const databaseUrl = requireTestDatabaseUrl(description); + return { + databaseUrl, + connectionStringFor: databaseName => connectionStringFor(databaseUrl, databaseName), + }; +} + +export async function createTestDatabase(adminClient, databaseName) { + await adminClient.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`); +} + +export async function dropTestDatabase(adminClient, databaseName) { + await adminClient.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(databaseName)} WITH (FORCE)`); +} + +export async function assertMinimumPostgresVersion(client, minimumVersion = 160000) { + const { rows: [server] } = await client.query( + "SELECT current_setting('server_version_num')::int AS version_num", + ); + if (server.version_num < minimumVersion) { + throw new Error( + `PostgreSQL ${minimumVersion} or newer is required; found ${server.version_num}`, + ); + } + return server.version_num; +} + +export function productionDatabaseEnvironment(encryptionKey) { + const keys = [ + 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'ENCRYPTION_KEY', + ]; + const snapshot = Object.fromEntries(keys.map(key => [key, process.env[key]])); + + let configuredUrl; + return { + configure(connectionString) { + const url = new URL(connectionString); + configuredUrl = url; + process.env.DB_HOST = url.hostname; + process.env.DB_PORT = url.port || '5432'; + process.env.DB_NAME = decodeURIComponent(url.pathname.slice(1)); + process.env.DB_USER = decodeURIComponent(url.username); + process.env.DB_PASSWORD = decodeURIComponent(url.password); + process.env.ENCRYPTION_KEY = encryptionKey; + }, + configurePool(pool) { + if (!configuredUrl) throw new Error('Production database environment is not configured'); + pool.options.host = configuredUrl.hostname; + pool.options.port = Number(configuredUrl.port || 5432); + pool.options.database = decodeURIComponent(configuredUrl.pathname.slice(1)); + pool.options.user = decodeURIComponent(configuredUrl.username); + pool.options.password = decodeURIComponent(configuredUrl.password); + }, + restore() { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }, + }; +} + +export async function applyTestMigrations(client, { + migrationsDirectory, + first = '0001', + through = '9999', + transactionPerMigration = false, +} = {}) { + const filenames = (await readdir(migrationsDirectory)) + .filter(filename => /^\d{4}_.+\.sql$/.test(filename)) + .filter(filename => filename.slice(0, 4) >= first) + .filter(filename => filename.slice(0, 4) <= through) + .sort(); + + for (const filename of filenames) { + const sql = await readFile(join(migrationsDirectory, filename), 'utf8'); + if (/^--\s*no-transaction\b/im.test(sql)) { + const statements = sql + .replace(/--[^\n]*/g, '') + .split(';') + .map(statement => statement.trim()) + .filter(Boolean); + for (const statement of statements) await client.query(statement); + continue; + } + + if (!transactionPerMigration) { + await client.query(sql); + continue; + } + + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + } +} + +export async function waitForPostgresState({ description, probe, timeoutMs = 10_000 }) { + const deadline = performance.now() + timeoutMs; + const probeTimedOut = Symbol('probe timed out'); + let lastState = null; + + while (performance.now() < deadline) { + const remainingMs = deadline - performance.now(); + let timeoutId; + let result; + try { + result = await Promise.race([ + Promise.resolve().then(probe), + new Promise(resolve => { + timeoutId = setTimeout(resolve, remainingMs, probeTimedOut); + }), + ]); + } finally { + clearTimeout(timeoutId); + } + + if (result === probeTimedOut || performance.now() >= deadline) break; + + const { done, state } = result; + lastState = state; + if (done) return state; + + const delayMs = Math.min(25, deadline - performance.now()); + if (delayMs > 0) { + await delay(delayMs); + } + } + + throw new Error( + `Timed out waiting for ${description} within ${timeoutMs} ms; ` + + `last observed state: ${JSON.stringify(lastState)}`, + ); +} diff --git a/backend/src/services/postgresTestHelpers.test.js b/backend/src/services/postgresTestHelpers.test.js new file mode 100644 index 00000000..431c137f --- /dev/null +++ b/backend/src/services/postgresTestHelpers.test.js @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { waitForPostgresState } from './postgresTestHelpers.js'; + +describe('waitForPostgresState', () => { + it('reports the bounded wait and final safe state when the probe never succeeds', async () => { + await expect(waitForPostgresState({ + description: 'mapping transaction to block', + timeoutMs: 50, + probe: async () => ({ done: false, state: { wait_event_type: null } }), + })).rejects.toThrow(/mapping transaction to block.*50 ms.*wait_event_type/); + }); + + it('rejects within the bound when a probe never resolves', async () => { + // Generous wall bound: the 30 ms internal deadline must win the race, but a + // heavily loaded host can stall the event loop for a while — keep the margin + // wide enough that a stall cannot let the wall timer fire first and flip the + // asserted rejection message. This only guards against a genuine hang. + const wallBoundMs = 2000; + let wallTimer; + const startedAt = performance.now(); + + try { + await expect(Promise.race([ + waitForPostgresState({ + description: 'stalled PostgreSQL probe', + timeoutMs: 30, + probe: () => new Promise(() => {}), + }), + new Promise((_, reject) => { + wallTimer = setTimeout( + () => reject(new Error(`Exceeded ${wallBoundMs} ms wall bound`)), + wallBoundMs, + ); + }), + ])).rejects.toThrow( + 'Timed out waiting for stalled PostgreSQL probe within 30 ms; last observed state: null', + ); + expect(performance.now() - startedAt).toBeLessThan(wallBoundMs); + } finally { + clearTimeout(wallTimer); + } + }); + + it('rejects a successful probe result that completes after the deadline', async () => { + let probeTimer; + let markProbeSettled; + const probeSettled = new Promise(resolve => { markProbeSettled = resolve; }); + const probeResult = new Promise(resolve => { + probeTimer = setTimeout(() => { + resolve({ done: true, state: { status: 'ready' } }); + markProbeSettled(); + }, 75); + }); + + try { + await expect(waitForPostgresState({ + description: 'late successful PostgreSQL probe', + timeoutMs: 20, + probe: () => probeResult, + })).rejects.toThrow( + 'Timed out waiting for late successful PostgreSQL probe within 20 ms; ' + + 'last observed state: null', + ); + await probeSettled; + } finally { + clearTimeout(probeTimer); + } + }); +}); diff --git a/backend/src/services/safeFetch.js b/backend/src/services/safeFetch.js index dd0f0973..03a0f792 100644 --- a/backend/src/services/safeFetch.js +++ b/backend/src/services/safeFetch.js @@ -34,6 +34,12 @@ function insecure() { { code: 'ERR_INSECURE_TRANSPORT' }, ); } +function redirectError(message, code) { + return Object.assign(new Error(message), { code }); +} + +const MAX_CREDENTIALED_REDIRECTS = 5; +const FOLLOWED_REDIRECT_STATUSES = new Set([301, 302, 307, 308]); // A connector that refuses plaintext (when required) and private/reserved addresses, // pinning the socket to a validated IP (with the original hostname kept for TLS SNI). @@ -64,15 +70,103 @@ function agentFor(allowPrivate, requireHttps) { return agents.get(key); } -export function safeFetch(url, options = {}, { allowPrivate = false, requireHttps = !allowPrivate } = {}) { +export function safeFetch(url, options = {}, { + allowPrivate = false, + requireHttps = !allowPrivate, + credentialOrigin, + validateRedirect, +} = {}) { let parsed; try { parsed = new URL(url); } catch { return Promise.reject(new Error('Invalid URL')); } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return Promise.reject(Object.assign(new Error('Only http(s) URLs are allowed'), { code: 'ERR_UNSUPPORTED_SCHEME' })); + const validationError = validateUrl(parsed, requireHttps); + if (validationError) return Promise.reject(validationError); + + if (credentialOrigin == null) { + return fetch(url, { ...options, dispatcher: agentFor(allowPrivate, requireHttps) }); + } + + let trustedOrigin; + try { trustedOrigin = new URL(credentialOrigin).origin; } + catch { return Promise.reject(new Error('Invalid credential origin')); } + if (parsed.origin !== trustedOrigin) { + return Promise.reject(redirectError( + 'Credentials cannot be sent to a different origin', + 'ERR_CROSS_ORIGIN_REDIRECT', + )); + } + + return fetchWithCredentialFence(parsed, options, { + allowPrivate, + requireHttps, + trustedOrigin, + validateRedirect, + }); +} + +function validateUrl(url, requireHttps) { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return Object.assign(new Error('Only http(s) URLs are allowed'), { code: 'ERR_UNSUPPORTED_SCHEME' }); + } + if (requireHttps && url.protocol !== 'https:') return insecure(); + return null; +} + +async function fetchWithCredentialFence(initialUrl, options, { + allowPrivate, + requireHttps, + trustedOrigin, + validateRedirect, +}) { + let currentUrl = initialUrl; + let redirectCount = 0; + + while (true) { + const response = await fetch(currentUrl, { + ...options, + redirect: 'manual', + dispatcher: agentFor(allowPrivate, requireHttps), + }); + const location = response.headers.get('location'); + if (!location || (!FOLLOWED_REDIRECT_STATUSES.has(response.status) && response.status !== 303)) { + return response; + } + + await cancelResponseBody(response); + if (response.status === 303) { + throw redirectError( + '303 redirects are not supported for credentialed requests', + 'ERR_UNSUPPORTED_REDIRECT', + ); + } + + let nextUrl; + try { nextUrl = new URL(location, currentUrl); } + catch { + throw redirectError('Redirect location is not a valid URL', 'ERR_INVALID_REDIRECT'); + } + const validationError = validateUrl(nextUrl, requireHttps); + if (validationError) throw validationError; + if (nextUrl.origin !== trustedOrigin) { + throw redirectError( + 'Credentialed redirects must stay on the configured origin', + 'ERR_CROSS_ORIGIN_REDIRECT', + ); + } + if (redirectCount >= MAX_CREDENTIALED_REDIRECTS) { + throw redirectError('Too many credentialed redirects', 'ERR_TOO_MANY_REDIRECTS'); + } + await validateRedirect?.(nextUrl.href); + + redirectCount += 1; + currentUrl = nextUrl; } - if (requireHttps && parsed.protocol !== 'https:') { - return Promise.reject(insecure()); +} + +async function cancelResponseBody(response) { + try { + await response.body?.cancel(); + } catch { + // The response is being discarded regardless; cancellation is best-effort. } - return fetch(url, { ...options, dispatcher: agentFor(allowPrivate, requireHttps) }); } diff --git a/backend/src/services/safeFetch.test.js b/backend/src/services/safeFetch.test.js index b184c6ec..d485729c 100644 --- a/backend/src/services/safeFetch.test.js +++ b/backend/src/services/safeFetch.test.js @@ -1,18 +1,74 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import http from 'node:http'; import { safeFetch } from './safeFetch.js'; -let server, port; +let server, port, redirectServer, redirectPort; +let serverBAuthorizations; +let serverBRequests; +let sameOriginRequests; +let chainRequests; beforeAll(async () => { + serverBAuthorizations = []; + serverBRequests = 0; + sameOriginRequests = []; + chainRequests = []; + redirectServer = http.createServer((req, res) => { + serverBRequests += 1; + serverBAuthorizations.push(req.headers.authorization); + res.writeHead(200); + res.end('cross-origin target'); + }); + await new Promise(r => redirectServer.listen(0, '127.0.0.1', r)); + redirectPort = redirectServer.address().port; + server = http.createServer((req, res) => { if (req.url === '/redir') { res.writeHead(302, { Location: '/ok2' }); return res.end(); } + if (req.url === '/cross') { + res.writeHead(302, { Location: `http://127.0.0.1:${redirectPort}/target` }); + return res.end(); + } + if (req.url?.startsWith('/same-origin/')) { + const [, , statusOrTarget] = req.url.split('/'); + if (statusOrTarget !== 'target') { + res.writeHead(Number(statusOrTarget), { Location: '/same-origin/target' }); + return res.end(); + } + let body = ''; + req.setEncoding('utf8'); + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + sameOriginRequests.push({ + authorization: req.headers.authorization, + body, + method: req.method, + }); + res.writeHead(200); + res.end('same-origin target'); + }); + return; + } + if (req.url?.startsWith('/chain/')) { + chainRequests.push(req.url); + const step = Number(req.url.slice('/chain/'.length)); + if (step < 6) { + res.writeHead(302, { Location: `/chain/${step + 1}` }); + return res.end(); + } + res.writeHead(200); + return res.end('redirect target'); + } if (req.url === '/ok' || req.url === '/ok2') { res.writeHead(200); return res.end('hi'); } res.writeHead(404); res.end(); }); await new Promise(r => server.listen(0, '127.0.0.1', r)); port = server.address().port; }); -afterAll(() => server && server.close()); +afterAll(async () => { + await Promise.all([ + new Promise(resolve => server?.close(resolve)), + new Promise(resolve => redirectServer?.close(resolve)), + ]); +}); describe('safeFetch — SSRF guard', () => { it('connects to a private IP when allowPrivate=true', async () => { @@ -39,12 +95,167 @@ describe('safeFetch — SSRF guard', () => { )).toBe('ERR_BLOCKED_PRIVATE_IP'); }); + it('blocks hexadecimal IPv4-mapped loopback before connecting', async () => { + let requestCount = 0; + const loopbackServer = http.createServer((_req, res) => { + requestCount += 1; + res.end('loopback reached'); + }); + await new Promise((resolve, reject) => { + loopbackServer.once('error', reject); + loopbackServer.listen(0, '127.0.0.1', resolve); + }); + const loopbackPort = loopbackServer.address().port; + + try { + expect(await causeCode( + safeFetch(`http://[::ffff:7f00:1]:${loopbackPort}`, {}, { + allowPrivate: false, + requireHttps: false, + }) + )).toBe('ERR_BLOCKED_PRIVATE_IP'); + expect(requestCount).toBe(0); + } finally { + await new Promise(resolve => loopbackServer.close(resolve)); + } + }); + it('follows redirects, validating each hop', async () => { const r = await safeFetch(`http://127.0.0.1:${port}/redir`, {}, { allowPrivate: true }); expect(r.status).toBe(200); }); }); +describe('safeFetch — credential origin policy', () => { + const credentialedRequest = { + method: 'PROPFIND', + headers: { Authorization: 'Basic secret' }, + body: '', + redirect: 'follow', + }; + + it('rejects a cross-origin redirect before sending the next request', async () => { + const origin = `http://127.0.0.1:${port}`; + + await expect(safeFetch(`${origin}/cross`, credentialedRequest, { + allowPrivate: true, + credentialOrigin: origin, + })).rejects.toMatchObject({ code: 'ERR_CROSS_ORIGIN_REDIRECT' }); + + expect(serverBRequests).toBe(0); + expect(serverBAuthorizations).toEqual([]); + }); + + it('rejects an initial URL outside the credential origin before sending a request', async () => { + const credentialOrigin = `http://127.0.0.1:${port}`; + const requestCount = serverBRequests; + + await expect(safeFetch(`http://127.0.0.1:${redirectPort}/target`, credentialedRequest, { + allowPrivate: true, + credentialOrigin, + })).rejects.toMatchObject({ code: 'ERR_CROSS_ORIGIN_REDIRECT' }); + + expect(serverBRequests).toBe(requestCount); + }); + + it.each([301, 302, 307, 308])( + 'preserves DAV method, body, and authorization across a same-origin %i redirect', + async status => { + const origin = `http://127.0.0.1:${port}`; + const response = await safeFetch(`${origin}/same-origin/${status}`, credentialedRequest, { + allowPrivate: true, + credentialOrigin: origin, + }); + + expect(await response.text()).toBe('same-origin target'); + expect(sameOriginRequests.at(-1)).toEqual({ + authorization: 'Basic secret', + body: '', + method: 'PROPFIND', + }); + }, + ); + + it('validates a same-origin redirect before issuing the next request', async () => { + const origin = `http://127.0.0.1:${port}`; + const requestCount = sameOriginRequests.length; + const redirectError = Object.assign(new Error('redirect escaped its resource scope'), { + code: 'ERR_DAV_HREF_SCOPE', + }); + const validateRedirect = vi.fn(() => { throw redirectError; }); + + await expect(safeFetch(`${origin}/same-origin/307`, credentialedRequest, { + allowPrivate: true, + credentialOrigin: origin, + validateRedirect, + })).rejects.toBe(redirectError); + + expect(validateRedirect).toHaveBeenCalledWith(`${origin}/same-origin/target`); + expect(sameOriginRequests).toHaveLength(requestCount); + }); + + it('cancels a redirect body before issuing the next same-origin request', async () => { + const originalFetch = globalThis.fetch; + const cancel = vi.fn().mockResolvedValue(undefined); + const redirected = { + body: { cancel }, + headers: new Headers({ Location: '/final' }), + status: 302, + }; + const finalResponse = { + body: null, + headers: new Headers(), + status: 207, + }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(redirected) + .mockImplementationOnce(async () => { + expect(cancel).toHaveBeenCalledOnce(); + return finalResponse; + }); + vi.stubGlobal('fetch', fetchMock); + + try { + await expect(safeFetch('https://example.com/start', credentialedRequest, { + credentialOrigin: 'https://example.com', + })).resolves.toBe(finalResponse); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + vi.stubGlobal('fetch', originalFetch); + } + }); + + it('rejects a 303 instead of changing an authenticated DAV request to GET', async () => { + const origin = `http://127.0.0.1:${port}`; + const requestCount = sameOriginRequests.length; + + await expect(safeFetch(`${origin}/same-origin/303`, credentialedRequest, { + allowPrivate: true, + credentialOrigin: origin, + })).rejects.toMatchObject({ code: 'ERR_UNSUPPORTED_REDIRECT' }); + + expect(sameOriginRequests).toHaveLength(requestCount); + }); + + it('rejects the sixth redirect without issuing a seventh request', async () => { + const origin = `http://127.0.0.1:${port}`; + + await expect(safeFetch(`${origin}/chain/0`, credentialedRequest, { + allowPrivate: true, + credentialOrigin: origin, + })).rejects.toMatchObject({ code: 'ERR_TOO_MANY_REDIRECTS' }); + + expect(chainRequests).toEqual([ + '/chain/0', + '/chain/1', + '/chain/2', + '/chain/3', + '/chain/4', + '/chain/5', + ]); + }); +}); + describe('safeFetch — scheme policy', () => { it('rejects non-http(s) schemes', async () => { await expect(safeFetch('ftp://example.com/x')).rejects.toThrow(/http\(s\)/i); diff --git a/backend/src/utils/vcard.js b/backend/src/utils/vcard.js index 4b8bf418..76864328 100644 --- a/backend/src/utils/vcard.js +++ b/backend/src/utils/vcard.js @@ -1,216 +1,50 @@ -// vCard 3.0 parser and generator (RFC 2426). -// Used by the contacts REST API and the CardDAV server. - -// Sanitize a vCard parameter value (e.g. TYPE=...). -// Strips CR, LF, and other characters that are structural in vCard lines. -function escapeParam(str) { - if (!str) return ''; - return str.replace(/[\r\n;:,]/g, ''); -} - -// Escape special characters in a vCard property value. -function escapeValue(str) { - if (!str) return ''; - return str - .replace(/\\/g, '\\\\') - .replace(/;/g, '\\;') - .replace(/,/g, '\\,') - .replace(/\n/g, '\\n') - .replace(/\r/g, ''); -} - -// Unescape a vCard property value. -function unescapeValue(str) { - if (!str) return ''; - return str - .replace(/\\n/gi, '\n') - .replace(/\\,/g, ',') - .replace(/\\;/g, ';') - .replace(/\\\\/g, '\\'); -} - -// Fold a vCard line at 75 octets per RFC 6350 §3.2. -function foldLine(line) { - const bytes = Buffer.from(line, 'utf8'); - if (bytes.length <= 75) return line + '\r\n'; - const parts = []; - let offset = 0; - let first = true; - while (offset < bytes.length) { - const max = first ? 75 : 74; // continuation lines have a leading space - // Walk back to a character boundary - let end = offset + max; - if (end >= bytes.length) { - end = bytes.length; - } else { - // back off until we're at a UTF-8 character boundary - while (end > offset && (bytes[end] & 0xC0) === 0x80) end--; - } - const chunk = bytes.slice(offset, end).toString('utf8'); - parts.push((first ? '' : ' ') + chunk); - offset = end; - first = false; - } - return parts.join('\r\n') + '\r\n'; -} - -// Unfold a raw vCard string — join lines that start with whitespace. -function unfold(raw) { - return raw.replace(/\r\n[ \t]/g, '').replace(/\n[ \t]/g, ''); -} +import { + contactFromVCardDocument, + overlayContactOnVCard, + parseVCardDocument, + serializeVCardDocument, +} from './vcardProperties.js'; /** - * Parse a vCard 3.0 string and return a plain object with the fields - * MailFlow cares about. Unknown properties are silently ignored. - * - * Returns: { uid, displayName, firstName, lastName, emails, phones, organization, notes, photoData } + * Parse a vCard into the compatibility contact shape used outside CardDAV sync. */ export function parseVCard(raw) { - const text = unfold(raw || ''); - const result = { - uid: null, - displayName: null, - firstName: null, - lastName: null, - emails: [], - phones: [], - organization: null, - notes: null, - photoData: null, - }; - - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed === 'BEGIN:VCARD' || trimmed === 'END:VCARD') continue; - - const colonIdx = trimmed.indexOf(':'); - if (colonIdx < 0) continue; - - const rawName = trimmed.slice(0, colonIdx).toUpperCase(); - const value = trimmed.slice(colonIdx + 1); - - // Strip parameters (e.g. "EMAIL;TYPE=WORK:..." → name = "EMAIL"), then drop an - // optional group prefix (e.g. "ITEM1.EMAIL" → "EMAIL") used by Apple/Nextcloud. - let name = rawName.split(';')[0]; - if (name.includes('.')) name = name.slice(name.lastIndexOf('.') + 1); - const params = rawName.includes(';') ? rawName.slice(rawName.indexOf(';') + 1) : ''; - - switch (name) { - case 'VERSION': break; // ignore - case 'UID': - result.uid = unescapeValue(value).trim(); - break; - case 'FN': - result.displayName = unescapeValue(value).trim() || null; - break; - case 'N': { - // N:Last;First;Additional;Prefix;Suffix - const parts = value.split(';').map(p => unescapeValue(p).trim()); - result.lastName = parts[0] || null; - result.firstName = parts[1] || null; - break; - } - case 'EMAIL': { - const emailVal = unescapeValue(value).trim().toLowerCase(); - if (emailVal) { - const typeMatch = params.match(/TYPE=([^;]+)/i); - const type = typeMatch ? typeMatch[1].toLowerCase().replace(/["']/g, '') : 'other'; - const isPrimary = result.emails.length === 0; - result.emails.push({ value: emailVal, type, primary: isPrimary }); - } - break; - } - case 'TEL': { - const phoneVal = unescapeValue(value).trim(); - if (phoneVal) { - const typeMatch = params.match(/TYPE=([^;]+)/i); - const type = typeMatch ? typeMatch[1].toLowerCase().replace(/["']/g, '') : 'other'; - result.phones.push({ value: phoneVal, type }); - } - break; - } - case 'ORG': - result.organization = unescapeValue(value.split(';')[0]).trim() || null; - break; - case 'NOTE': - result.notes = unescapeValue(value).trim() || null; - break; - case 'PHOTO': { - const v = value.trim(); - if (!v) break; - if (v.startsWith('data:')) { - // vCard 4.0 inline data URI — store as-is. - result.photoData = v; - } else if (/^https?:\/\//i.test(v)) { - // External URL — skip to avoid privacy leak / fetch complexity. - result.photoData = null; - } else { - // vCard 3.0 ENCODING=b raw base64 — derive MIME from TYPE param. - const typeMatch = params.match(/TYPE=([^;]+)/i); - const rawType = typeMatch ? typeMatch[1].replace(/["']/g, '').toUpperCase() : 'JPEG'; - const mimeMap = { JPEG: 'image/jpeg', JPG: 'image/jpeg', PNG: 'image/png', GIF: 'image/gif', WEBP: 'image/webp' }; - const mimeType = mimeMap[rawType] || 'image/jpeg'; - result.photoData = `data:${mimeType};base64,${v}`; - } - break; - } - } + const text = raw == null ? '' : String(raw); + const document = parseVCardDocument(text, { defaultVersion: '3.0' }); + const contact = contactFromVCardDocument(document); + if (contact.emails.length && !contact.emails.some(email => email.primary)) { + contact.emails[0].primary = true; } - - return result; + return contact; } /** - * Generate a vCard 3.0 string from a contact object. - * - * contact: { uid, displayName, firstName, lastName, emails, phones, organization, notes } + * Generate a deterministic vCard 3.0 for general-purpose contact consumers. */ export function generateVCard(contact) { - const { - uid, - displayName, - firstName, - lastName, - emails = [], - phones = [], - organization, - notes, - } = contact; - - const lines = []; - lines.push('BEGIN:VCARD'); - lines.push('VERSION:3.0'); - lines.push(`UID:${escapeValue(uid || '')}`); - - const fn = displayName - || (firstName || lastName ? [firstName, lastName].filter(Boolean).join(' ') : null) - || (emails[0]?.value ?? ''); - lines.push(`FN:${escapeValue(fn)}`); - - if (firstName || lastName) { - lines.push(`N:${escapeValue(lastName || '')};${escapeValue(firstName || '')};;;`); - } - - for (const e of emails) { - const type = escapeParam((e.type || 'other').toUpperCase()); - lines.push(`EMAIL;TYPE=${type}:${escapeValue(e.value || '')}`); - } - - for (const p of phones) { - const type = escapeParam((p.type || 'voice').toUpperCase()); - lines.push(`TEL;TYPE=${type}:${escapeValue(p.value || '')}`); - } - - if (organization) { - lines.push(`ORG:${escapeValue(organization)}`); - } - - if (notes) { - lines.push(`NOTE:${escapeValue(notes)}`); - } - - lines.push('END:VCARD'); - - // Fold and join - return lines.map(foldLine).join(''); + const firstName = contact.firstName ?? contact.first_name; + const lastName = contact.lastName ?? contact.last_name; + const email = contact.emails?.[0]?.value ?? contact.emails?.[0]?.email ?? ''; + const displayName = contact.displayName ?? contact.display_name; + const emails = (contact.emails || []).map(emailEntry => { + const entry = { ...emailEntry }; + delete entry.primary; + delete entry.isPrimary; + delete entry.is_primary; + return entry; + }); + const compatibilityContact = { + ...contact, + emails, + displayName: displayName + || ([firstName, lastName].filter(Boolean).join(' ') || email), + }; + const document = overlayContactOnVCard({ + version: '3.0', + properties: [ + { group: null, name: 'UID', params: [], rawValue: '' }, + { group: null, name: 'FN', params: [], rawValue: '' }, + ], + }, compatibilityContact); + return serializeVCardDocument(document); } diff --git a/backend/src/utils/vcard.test.js b/backend/src/utils/vcard.test.js new file mode 100644 index 00000000..db26a664 --- /dev/null +++ b/backend/src/utils/vcard.test.js @@ -0,0 +1,241 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { generateVCard, parseVCard } from './vcard.js'; +import { contactFromVCardDocument, parseVCardDocument } from './vcardProperties.js'; + +const contact = (photoData) => ({ + uid: 'photo-test', + displayName: 'Photo Test', + emails: [{ value: 'photo@example.test', type: 'other' }], + phones: [], + photoData, +}); + +describe('vCard photo serialization', () => { + it.each([ + ['image/jpeg', 'JPEG'], + ['image/png', 'PNG'], + ['image/gif', 'GIF'], + ['image/webp', 'WEBP'], + ])('round-trips folded %s data URIs', (mime, type) => { + const payload = 'A'.repeat(180); + const photoData = `data:${mime};base64,${payload}`; + const vcard = generateVCard(contact(photoData)); + + expect(vcard).toContain(`PHOTO;ENCODING=b;TYPE=${type}:`); + expect(vcard).toMatch(/PHOTO;ENCODING=b;TYPE=.*\r\n /); + expect(parseVCard(vcard).photoData).toBe(photoData); + }); + + it('makes photo-only changes byte-visible and keeps external URLs out', () => { + const first = generateVCard(contact('data:image/png;base64,AAA')); + const second = generateVCard(contact('data:image/png;base64,BBA')); + const external = generateVCard(contact('https://images.example.test/photo.png')); + const etag = value => createHash('md5').update(value).digest('hex'); + + expect(first).not.toBe(second); + expect(etag(first)).not.toBe(etag(second)); + expect(generateVCard(contact('data:image/png;base64,AAA'))).toBe(first); + expect(external).not.toMatch(/^PHOTO/m); + expect(parseVCard(external).photoData).toBeNull(); + }); +}); + +describe('vCard type parsing', () => { + it('strips double quotes from generated email types', () => { + expect(generateVCard({ + uid: 'quoted-email-type', + displayName: 'Quoted Email Type', + emails: [{ value: 'quoted@example.test', type: 'wo"rk' }], + phones: [], + })).toContain('EMAIL;TYPE=WORK:quoted@example.test\r\n'); + }); + + it('maps compound TYPE parameters to MailFlow contact labels', () => { + const parsed = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nUID:types\r\nFN:Type Test\r\nEMAIL;TYPE=INTERNET,WORK:work@example.test\r\nEMAIL;TYPE="HOME,INTERNET":home@example.test\r\nTEL;TYPE=CELL,VOICE,PREF:+15550000001\r\nTEL;TYPE=WORK,VOICE:+15550000002\r\nTEL;TYPE=VOICE,PREF:+15550000003\r\nEND:VCARD\r\n`); + + expect(parsed.emails.map(({ type }) => type)).toEqual(['work', 'home']); + expect(parsed.phones.map(({ type }) => type)).toEqual(['mobile', 'work', 'other']); + }); + + it('keeps accepting single-quoted compound TYPE values', () => { + const parsed = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nUID:quoted\r\nFN:Quoted\r\nEMAIL;TYPE='HOME,INTERNET':home@example.test\r\nEND:VCARD\r\n`); + + expect(parsed.emails[0].type).toBe('home'); + }); + + it.each(['3.0', '4.0'])('keeps first-email fallback for unmarked vCard %s emails', version => { + const parsed = parseVCard([ + 'BEGIN:VCARD', + `VERSION:${version}`, + 'EMAIL:first@example.test', + 'EMAIL:second@example.test', + 'END:VCARD', + '', + ].join('\r\n')); + + expect(parsed.emails.map(email => email.primary)).toEqual([true, false]); + }); +}); + +describe('vCard compatibility facade', () => { + it.each([ + 'status:open', + 'sip:alice@example.test', + 'webcal:event-id', + ])('matches strict parsing for colon-bearing NOTE value %s', value => { + const raw = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `NOTE;X-P=trailing\\:${value}`, + 'END:VCARD', + '', + ].join('\r\n'); + const strict = contactFromVCardDocument(parseVCardDocument(raw)); + const compatible = parseVCard(raw); + + expect(strict.notes).toBe(value); + expect(compatible.notes).toBe(value); + }); + + it('keeps defaulting versionless legacy consumer cards to vCard 3.0', () => { + expect(parseVCard([ + 'BEGIN:VCARD', + 'UID:legacy-versionless', + 'FN:Legacy Versionless', + 'END:VCARD', + '', + ].join('\r\n'))).toMatchObject({ + uid: 'legacy-versionless', + displayName: 'Legacy Versionless', + }); + }); + + it('preserves malformed version error surfaces while defaulting legacy cards', () => { + expect(() => parseVCard('BEGIN:VCARD\r\nVERSION\r\nEND:VCARD\r\n')) + .toThrow('vCard contains an invalid content line'); + expect(() => parseVCard('BEGIN:VCARD\r\n')) + .toThrow('vCard must end with END:VCARD'); + }); + + it.each([ + ['empty', {}, []], + ['phone-only', { + phones: [{ value: '+15551234567', type: 'mobile' }], + }, ['TEL;TYPE=MOBILE:+15551234567']], + ])('keeps legacy UID and mandatory FN placeholders for %s contacts', ( + _case, + contact, + extraLines, + ) => { + expect(generateVCard(contact)).toBe([ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:', + 'FN:', + ...extraLines, + 'END:VCARD', + '', + ].join('\r\n')); + }); + + it('keeps deterministic vCard 3.0 bytes for existing contact consumers', () => { + const vcard = generateVCard({ + uid: 'deterministic', + displayName: 'Jane Doe', + firstName: 'Jane', + lastName: 'Doe', + emails: [{ value: 'jane@example.test', type: 'work', primary: true }], + phones: [{ value: '+15551234567', type: 'mobile' }], + organization: 'Example Corp', + notes: 'Line one\nLine two', + }); + + expect(vcard).toBe([ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'UID:deterministic', + 'FN:Jane Doe', + 'N:Doe;Jane;;;', + 'EMAIL;TYPE=WORK:jane@example.test', + 'TEL;TYPE=MOBILE:+15551234567', + 'ORG:Example Corp', + 'NOTE:Line one\\nLine two', + 'END:VCARD', + '', + ].join('\r\n')); + expect(generateVCard(parseVCard(vcard))).toBe(vcard); + }); + + it('keeps deriving FN only for newly generated compatibility cards', () => { + const generated = generateVCard({ + firstName: 'Jane', + lastName: 'Doe', + emails: [], + phones: [], + }); + const generatedFromBlank = generateVCard({ + displayName: '', + firstName: 'Jane', + lastName: 'Doe', + emails: [], + phones: [], + }); + + expect(generated).toContain('FN:Jane Doe\r\n'); + expect(generatedFromBlank).toContain('FN:Jane Doe\r\n'); + }); + + it('keeps the last PHOTO value, including a trailing URL clear', () => { + const withTwoEmbedded = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'PHOTO;ENCODING=b;TYPE=PNG:AQID', + 'PHOTO;ENCODING=b;TYPE=PNG:BAUG', + 'END:VCARD', + '', + ].join('\r\n'); + const withTrailingUrl = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'PHOTO;ENCODING=b;TYPE=PNG:AQID', + 'PHOTO;VALUE=URI:https://images.example.test/photo.png', + 'END:VCARD', + '', + ].join('\r\n'); + + expect(parseVCard(withTwoEmbedded).photoData).toBe('data:image/png;base64,BAUG'); + expect(parseVCard(withTrailingUrl).photoData).toBeNull(); + }); + + it('projects supported Additional fields without exposing raw syntax', () => { + const parsed = parseVCard([ + 'BEGIN:VCARD', + 'VERSION:4.0', + 'UID:additional', + 'FN:Additional', + 'item1.URL:https://example.test', + 'item1.X-ABLabel:Portfolio', + 'END:VCARD', + '', + ].join('\r\n')); + + expect(parsed.additionalFields).toEqual([ + expect.objectContaining({ + kind: 'url', + label: 'Portfolio', + value: 'https://example.test', + }), + ]); + }); + + it('rejects unsupported non-base64 data URI photos', () => { + expect(() => generateVCard({ + uid: 'data-uri', + displayName: 'Data URI', + emails: [], + phones: [], + photoData: 'data:image/svg+xml,', + })).toThrow(/unsupported PHOTO MIME type/); + }); +}); diff --git a/backend/src/utils/vcardDocument.js b/backend/src/utils/vcardDocument.js new file mode 100644 index 00000000..8668f25d --- /dev/null +++ b/backend/src/utils/vcardDocument.js @@ -0,0 +1,657 @@ +const MAX_VCARD_BYTES = 1024 * 1024; +const MAX_PROPERTIES = 2000; +const MAX_PARAMETERS = 64; +const MAX_CONTENT_LINE_BYTES = 64 * 1024; +const MAX_PHOTO_BYTES = 512 * 1024; +const VCARD_TOKEN = /^[A-Za-z0-9-]+$/; +export const VCARD_3_URI_DEFAULTS = new Set(['URL', 'IMPP']); +export const VCARD_4_URI_DEFAULTS = new Set([ + 'SOURCE', + 'PHOTO', + 'TEL', + 'IMPP', + 'GEO', + 'LOGO', + 'MEMBER', + 'RELATED', + 'SOUND', + 'UID', + 'URL', + 'KEY', + 'FBURL', + 'CALADRURI', + 'CALURI', +]); +const SUPPORTED_PHOTO_MIME_TYPES = new Map([ + ['image/jpeg', { mime: 'image/jpeg', type: 'JPEG' }], + ['image/jpg', { mime: 'image/jpeg', type: 'JPEG' }], + ['image/png', { mime: 'image/png', type: 'PNG' }], + ['image/gif', { mime: 'image/gif', type: 'GIF' }], + ['image/webp', { mime: 'image/webp', type: 'WEBP' }], +]); +export const PHOTO_IMAGE_TYPE_TOKENS = new Set( + [...SUPPORTED_PHOTO_MIME_TYPES.keys()].map(mime => mime.slice('image/'.length).toUpperCase()), +); +export const ADR_COMPONENTS = [ + 'poBox', + 'extendedAddress', + 'street', + 'locality', + 'region', + 'postalCode', + 'country', +]; + +export const SERVER_OWNED_PROPERTIES = new Set([ + 'PRODID', + 'REV', + 'SOURCE', + 'CREATED', + 'LAST-MODIFIED', +]); + +function byteLength(value) { + return Buffer.byteLength(value, 'utf8'); +} + +function percentDecodedByteLength(value) { + let bytes = 0; + let literalStart = 0; + + for (let index = 0; index < value.length; index++) { + if (value[index] !== '%') continue; + bytes += byteLength(value.slice(literalStart, index)); + if (!/^[0-9A-Fa-f]{2}$/.test(value.slice(index + 1, index + 3))) { + throw new Error('invalid percent encoding'); + } + bytes++; + index += 2; + literalStart = index + 1; + } + + return bytes + byteLength(value.slice(literalStart)); +} + +function decodePercentEncodedPhoto(value) { + const decodedLength = percentDecodedByteLength(value); + if (decodedLength > MAX_PHOTO_BYTES) { + throw new Error('vCard exceeds the 512 KiB photo limit'); + } + + const decoded = Buffer.allocUnsafe(decodedLength); + let offset = 0; + let literalStart = 0; + for (let index = 0; index < value.length; index++) { + if (value[index] !== '%') continue; + if (index > literalStart) { + offset += decoded.write(value.slice(literalStart, index), offset, 'utf8'); + } + decoded[offset++] = Number.parseInt(value.slice(index + 1, index + 3), 16); + index += 2; + literalStart = index + 1; + } + if (literalStart < value.length) decoded.write(value.slice(literalStart), offset, 'utf8'); + return decoded; +} + +function splitPhysicalLines(text) { + const lines = text.split(/\r\n|\n|\r/); + for (const line of lines) { + if (byteLength(line) > MAX_CONTENT_LINE_BYTES) { + throw new Error('vCard exceeds the 64 KiB physical line limit'); + } + } + return lines; +} + +function unfoldLines(lines) { + const unfolded = []; + let current = null; + + for (const line of lines) { + if (/^[ \t]/.test(line) && current !== null) { + current += line.slice(1); + continue; + } + if (current !== null) unfolded.push(current); + current = line; + } + if (current !== null) unfolded.push(current); + + return unfolded; +} + +function delimiterIndex(value, delimiter, backslashEscapes = true) { + let escaped = false; + let quoted = false; + + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (escaped) { + escaped = false; + continue; + } + if (backslashEscapes && character === '\\') { + escaped = true; + continue; + } + if (character === '"') { + quoted = !quoted; + continue; + } + if (character === delimiter && !quoted) return index; + } + + return -1; +} + +export function splitDelimited(value, delimiter, backslashEscapes = true) { + const parts = []; + let rest = value; + + while (true) { + const index = delimiterIndex(rest, delimiter, backslashEscapes); + if (index < 0) { + parts.push(rest); + break; + } + parts.push(rest.slice(0, index)); + rest = rest.slice(index + 1); + } + + return parts; +} + +function decodeParameterValue(value, version) { + if (version !== '4.0') return value; + let result = ''; + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (character !== '^' || index + 1 >= value.length) { + result += character; + continue; + } + + const next = value[++index]; + if (next === '^') result += '^'; + else if (next === 'n' || next === 'N') result += '\n'; + else if (next === "'") result += '"'; + else result += '^' + next; + } + return result; +} + +function quotedParameterValue(value, quote = '"') { + let start = 0; + let end = value.length; + while (start < end && /[ \t]/.test(value[start])) start++; + while (end > start && /[ \t]/.test(value[end - 1])) end--; + if (value[start] !== quote || value[end - 1] !== quote) return null; + return value.slice(start + 1, end - 1); +} + +function parseParameterValues(name, encodedValue, version) { + if (name === 'TYPE' && version === '3.0') { + const legacy = quotedParameterValue(encodedValue, "'"); + if (legacy !== null) return legacy.split(','); + } + + const entries = splitDelimited(encodedValue, ',', false); + if (entries.length === 0) return ['']; + if (entries.length === 1) { + const quoted = quotedParameterValue(entries[0]); + if (quoted !== null) { + const decoded = decodeParameterValue(quoted, version); + return name === 'TYPE' ? decoded.split(',') : [decoded]; + } + } + return entries.map(entry => { + const quoted = quotedParameterValue(entry); + return decodeParameterValue(quoted === null ? entry : quoted, version); + }); +} + +function hasForbiddenParameterControl(value, version) { + return [...String(value)].some(character => { + const codePoint = character.codePointAt(0); + return codePoint === 127 || ( + codePoint < 32 + && character !== '\t' + && (version !== '4.0' || (character !== '\r' && character !== '\n')) + ); + }); +} + +function hasInvalidParameterValue(value, version) { + return (version !== '4.0' && String(value).includes('"')) + || hasForbiddenParameterControl(value, version); +} + +function parseParameters(parts, version) { + if (parts.length > MAX_PARAMETERS) { + throw new Error('vCard property exceeds the 64 parameters limit'); + } + + return parts.map(part => { + const equals = delimiterIndex(part, '=', false); + if (equals < 0) { + const values = [decodeParameterValue(part, version)]; + if (values.some(value => hasInvalidParameterValue(value, version))) { + throw new Error('vCard parameter value contains an invalid character'); + } + return { name: 'TYPE', values }; + } + + const name = part.slice(0, equals).toUpperCase(); + if (!VCARD_TOKEN.test(name)) { + throw new Error('vCard contains an invalid parameter name'); + } + const encodedValue = part.slice(equals + 1); + const values = parseParameterValues(name, encodedValue, version); + if (values.some(value => hasInvalidParameterValue(value, version))) { + throw new Error('vCard parameter value contains an invalid character'); + } + return { + name, + values, + }; + }); +} + +function parseContentLine(line, version) { + const colon = delimiterIndex(line, ':', false); + if (colon < 0) throw new Error('vCard contains an invalid content line'); + + const header = line.slice(0, colon); + const rawValue = line.slice(colon + 1); + const parts = splitDelimited(header, ';', false); + let name = parts.shift(); + let group = null; + const dot = name.indexOf('.'); + if (dot >= 0) { + group = name.slice(0, dot); + name = name.slice(dot + 1); + } + if (group !== null && !VCARD_TOKEN.test(group)) { + throw new Error('vCard contains an invalid property group'); + } + name = name.toUpperCase(); + if (!VCARD_TOKEN.test(name)) throw new Error('vCard contains an invalid property name'); + + return { + group, + name, + params: parseParameters(parts, version), + rawValue, + }; +} + +export function parameterValues(property, name) { + return property.params + .filter(parameter => parameter.name === name) + .flatMap(parameter => parameter.values); +} + +export function decodeBase64Photo(value) { + let dataLength = 0; + let paddingLength = 0; + let paddingStarted = false; + for (const character of value) { + if (/\s/.test(character)) continue; + if (/^[A-Za-z0-9+/]$/.test(character) && !paddingStarted) { + dataLength++; + continue; + } + if (character === '=') { + paddingStarted = true; + paddingLength++; + continue; + } + throw new Error('vCard PHOTO has invalid base64 data'); + } + + const remainder = dataLength % 4; + const validPadding = paddingLength === 0 || ( + (dataLength + paddingLength) % 4 === 0 + && ((paddingLength === 1 && remainder === 3) + || (paddingLength === 2 && remainder === 2)) + ); + if (paddingLength > 2 + || remainder === 1 + || !validPadding) { + throw new Error('vCard PHOTO has invalid base64 data'); + } + if (Math.floor(dataLength * 6 / 8) > MAX_PHOTO_BYTES) { + throw new Error('vCard exceeds the 512 KiB photo limit'); + } + + const compact = value.replace(/\s/g, ''); + const withoutPadding = compact.slice(0, dataLength); + const decoded = Buffer.from(withoutPadding, 'base64'); + if (decoded.toString('base64').replace(/=+$/, '') !== withoutPadding) { + throw new Error('vCard PHOTO has invalid base64 data'); + } + if (decoded.length > MAX_PHOTO_BYTES) { + throw new Error('vCard exceeds the 512 KiB photo limit'); + } + return decoded; +} + +export function photoKind(property, version) { + const value = property.rawValue.trim(); + if (!value) return 'empty'; + const valueType = parameterValues(property, 'VALUE')[0]; + if (valueType !== undefined && /^uri$/i.test(valueType)) return 'url'; + const encodings = parameterValues(property, 'ENCODING'); + if (encodings.some(encoding => /^(?:b|base64)$/i.test(encoding))) return 'base64'; + if (valueType !== undefined || encodings.length) return 'legacy-base64'; + if (/^https?:\/\//i.test(value)) return 'url'; + if (/^data:/i.test(value)) return 'data-uri'; + if (version === '4.0') return 'url'; + return 'legacy-base64'; +} + +export function dataUriMimeType(value) { + const dataUri = String(value).match(/^data:([^,]*),/is); + return dataUri ? dataUri[1].split(';')[0].trim().toLowerCase() : null; +} + +export function supportedPhotoMimeType(value) { + return SUPPORTED_PHOTO_MIME_TYPES.get(String(value || '').trim().toLowerCase()) || null; +} + +export function retainedPhotoParameters(params, { retainImageTypeValues = false } = {}) { + return params.flatMap(parameter => { + const name = String(parameter.name).toUpperCase(); + if (/^(?:ENCODING|MEDIATYPE|VALUE)$/.test(name)) return []; + if (name !== 'TYPE' || retainImageTypeValues) return [structuredClone(parameter)]; + const values = parameter.values.filter(value => ( + !PHOTO_IMAGE_TYPE_TOKENS.has(String(value).toUpperCase()) + )); + return values.length ? [{ ...structuredClone(parameter), values }] : []; + }); +} + +export function canonicalSupportedPhotoDataUri(value) { + const dataUri = String(value).match(/^data:([^,]*),(.*)$/is); + if (!dataUri) return null; + const supported = supportedPhotoMimeType(dataUri[1].split(';')[0]); + if (!supported) return null; + const bytes = /(?:^|;)base64(?:;|$)/i.test(dataUri[1]) + ? decodeBase64Photo(dataUri[2]) + : decodePercentEncodedPhoto(dataUri[2]); + return 'data:' + supported.mime + ';base64,' + bytes.toString('base64'); +} + +export function embeddedPhotoMimeType(property, version) { + const kind = photoKind(property, version); + if (kind === 'data-uri') return dataUriMimeType(property.rawValue); + if (kind === 'legacy-base64') return 'image/jpeg'; + if (kind !== 'base64') return null; + + const mediaType = parameterValues(property, 'MEDIATYPE')[0]; + if (mediaType) return mediaType.toLowerCase(); + const type = parameterValues(property, 'TYPE')[0]; + if (!type) return 'image/jpeg'; + const supported = SUPPORTED_PHOTO_MIME_TYPES.get(`image/${type.toLowerCase()}`); + return supported?.mime || type.toLowerCase(); +} + +function validatePhoto(property, version) { + const value = property.rawValue.trim(); + const kind = photoKind(property, version); + if (kind === 'empty' || kind === 'url') return; + if (kind === 'base64' || kind === 'legacy-base64') { + decodeBase64Photo(value); + return; + } + + const dataUri = value.match(/^data:([^,]*),(.*)$/is); + if (!dataUri) throw new Error('vCard PHOTO has invalid data URI encoding'); + if (/(?:^|;)base64(?:;|$)/i.test(dataUri[1])) { + decodeBase64Photo(dataUri[2]); + return; + } + let decodedBytes; + try { + decodedBytes = percentDecodedByteLength(dataUri[2]); + } catch { + throw new Error('vCard PHOTO has invalid data URI encoding'); + } + if (decodedBytes > MAX_PHOTO_BYTES) { + throw new Error('vCard exceeds the 512 KiB photo limit'); + } +} + +function validatedEmbeddedPhoto(property, version) { + const kind = photoKind(property, version); + if (kind === 'empty' || kind === 'url') return false; + validatePhoto(property, version); + return true; +} + +export function parseVCardDocument(raw, { defaultVersion } = {}) { + const text = raw == null ? '' : String(raw); + const effectiveDefaultVersion = defaultVersion + && /^(?:BEGIN:VCARD)(?:\r\n|\n|\r)/i.test(text) + && !/(?:^|\r\n|\n|\r)VERSION(?:[;:]|$)/i.test(text) + ? defaultVersion + : null; + const lines = unfoldLines(splitPhysicalLines(text)); + const canonicalBytes = lines.reduce((bytes, line, index) => ( + index === lines.length - 1 && line === '' + ? bytes + : bytes + byteLength(line) + 2 + ), 0); + if (canonicalBytes > MAX_VCARD_BYTES) throw new Error('vCard exceeds the 1 MiB limit'); + + let state = 'before'; + let version = null; + let countedProperties = 0; + const contentLines = []; + + for (const line of lines) { + if (!line) continue; + const structural = line.toUpperCase(); + if (state === 'before') { + if (structural !== 'BEGIN:VCARD') { + throw new Error('vCard must begin with BEGIN:VCARD'); + } + state = 'version'; + continue; + } + if (state === 'ended') { + throw new Error('vCard must contain exactly one vCard component'); + } + + const colon = delimiterIndex(line, ':', false); + const header = colon < 0 ? '' : line.slice(0, colon); + const structuralName = header.split(';', 1)[0].split('.').at(-1).toUpperCase(); + if (structuralName === 'VERSION' && header.toUpperCase() !== 'VERSION') { + throw new Error('vCard VERSION parameters are not supported'); + } + if (/^(?:BEGIN|END)$/.test(structuralName) + && structural !== 'BEGIN:VCARD' + && structural !== 'END:VCARD') { + throw new Error('vCard document cannot contain structural properties'); + } + + if (state === 'version') { + if (header.toUpperCase() !== 'VERSION') { + if (!effectiveDefaultVersion) throw new Error('vCard VERSION must follow BEGIN:VCARD'); + version = effectiveDefaultVersion; + state = 'content'; + } else { + if (byteLength(line) > MAX_CONTENT_LINE_BYTES) { + throw new Error('vCard exceeds the 64 KiB unfolded line limit'); + } + version = line.slice(colon + 1).trim(); + state = 'content'; + continue; + } + } + + if (structural === 'END:VCARD') { + state = 'ended'; + continue; + } + if (structural === 'BEGIN:VCARD') { + throw new Error('vCard must contain exactly one vCard component'); + } + countedProperties++; + if (countedProperties > MAX_PROPERTIES) { + throw new Error('vCard exceeds the 2,000 properties limit'); + } + if (header.toUpperCase() === 'VERSION') { + throw new Error('vCard must contain exactly one VERSION property'); + } + contentLines.push(line); + } + + if (state === 'before') throw new Error('vCard must begin with BEGIN:VCARD'); + if (state === 'version' && effectiveDefaultVersion) { + version = effectiveDefaultVersion; + state = 'content'; + } + if (state === 'version') throw new Error('vCard VERSION must follow BEGIN:VCARD'); + if (state !== 'ended') throw new Error('vCard must end with END:VCARD'); + + if (version !== '3.0' && version !== '4.0') { + throw new Error('vCard version must be 3.0 or 4.0'); + } + + const properties = []; + for (const line of contentLines) { + const property = parseContentLine(line, version); + const embeddedPhoto = property.name === 'PHOTO' + && validatedEmbeddedPhoto(property, version); + if (!embeddedPhoto && byteLength(line) > MAX_CONTENT_LINE_BYTES) { + throw new Error('vCard exceeds the 64 KiB unfolded line limit'); + } + properties.push(property); + } + + return { version, properties }; +} + +function encodeParameterValue(value, version) { + const text = String(value); + if (version !== '4.0') { + if (hasInvalidParameterValue(text, version)) { + throw new Error('vCard parameter value contains an invalid character'); + } + return text; + } + let encoded = ''; + + for (let index = 0; index < text.length; index++) { + const character = text[index]; + if (hasForbiddenParameterControl(character, version)) { + throw new Error('vCard parameter value contains an invalid character'); + } + if (character === '^') encoded += '^^'; + else if (character === '"') encoded += "^'"; + else if (character === '\r') { + if (text[index + 1] === '\n') index++; + encoded += '^n'; + } else if (character === '\n') encoded += '^n'; + else encoded += character; + } + return encoded; +} + +function serializeParameter(parameter, version) { + const name = String(parameter.name || '').toUpperCase(); + if (!VCARD_TOKEN.test(name)) throw new Error('vCard contains an invalid parameter name'); + const values = (parameter.values || []).map(value => { + const encoded = encodeParameterValue(value, version); + return /[\\,;:"]/.test(encoded) || /^\s|\s$/.test(encoded) + ? `"${encoded}"` + : encoded; + }); + return `${name}=${values.join(',')}`; +} + +function foldLine(line) { + const bytes = Buffer.from(line, 'utf8'); + if (bytes.length <= 75) return `${line}\r\n`; + + const parts = []; + let offset = 0; + let first = true; + while (offset < bytes.length) { + const width = first ? 75 : 74; + let end = Math.min(offset + width, bytes.length); + while (end > offset && end < bytes.length && (bytes[end] & 0xC0) === 0x80) end--; + parts.push(`${first ? '' : ' '}${bytes.subarray(offset, end).toString('utf8')}`); + offset = end; + first = false; + } + return `${parts.join('\r\n')}\r\n`; +} + +function serializeProperty(property, version) { + const groupName = property.group ? String(property.group) : ''; + if (groupName && !VCARD_TOKEN.test(groupName)) { + throw new Error('vCard contains an invalid property group'); + } + const group = groupName ? `${groupName}.` : ''; + const name = String(property.name || '').toUpperCase(); + if (!VCARD_TOKEN.test(name)) throw new Error('vCard contains an invalid property name'); + if (/^(?:BEGIN|END|VERSION)$/.test(name)) { + throw new Error('vCard document cannot contain structural properties'); + } + const params = property.params || []; + if (params.length > MAX_PARAMETERS) { + throw new Error('vCard property exceeds the 64 parameters limit'); + } + + const rawValue = String(property.rawValue ?? ''); + if (/[\r\n]/.test(rawValue)) { + throw new Error('vCard property value contains a line break'); + } + const header = [group + name, ...params.map(parameter => ( + serializeParameter(parameter, version) + ))].join(';'); + const lineBytes = byteLength(header) + 1 + byteLength(rawValue); + if (lineBytes + 2 > MAX_VCARD_BYTES) { + throw new Error('vCard exceeds the 1 MiB limit'); + } + return { name, params, header, rawValue, lineBytes }; +} + +export function serializeVCardDocument(document) { + const version = String(document?.version || '3.0'); + if (version !== '3.0' && version !== '4.0') { + throw new Error('vCard version must be 3.0 or 4.0'); + } + + const properties = document?.properties || []; + if (properties.length > MAX_PROPERTIES) { + throw new Error('vCard exceeds the 2,000 properties limit'); + } + + let canonicalBytes = 0; + let serialized = ''; + const appendLine = (line, lineBytes, property = null) => { + if (canonicalBytes + lineBytes + 2 > MAX_VCARD_BYTES) { + throw new Error('vCard exceeds the 1 MiB limit'); + } + canonicalBytes += lineBytes + 2; + const embeddedPhoto = property?.name === 'PHOTO' + && validatedEmbeddedPhoto(property, version); + if (property && !embeddedPhoto && lineBytes > MAX_CONTENT_LINE_BYTES) { + throw new Error('vCard exceeds the 64 KiB unfolded line limit'); + } + serialized += foldLine(line); + }; + + appendLine('BEGIN:VCARD', byteLength('BEGIN:VCARD')); + appendLine(`VERSION:${version}`, byteLength(`VERSION:${version}`)); + for (const property of properties) { + const encoded = serializeProperty(property, version); + appendLine(`${encoded.header}:${encoded.rawValue}`, encoded.lineBytes, encoded); + } + appendLine('END:VCARD', byteLength('END:VCARD')); + return serialized; +} diff --git a/backend/src/utils/vcardHashes.js b/backend/src/utils/vcardHashes.js new file mode 100644 index 00000000..ef487ee8 --- /dev/null +++ b/backend/src/utils/vcardHashes.js @@ -0,0 +1,192 @@ +import { createHash } from 'node:crypto'; + +import { + ADR_COMPONENTS, + SERVER_OWNED_PROPERTIES, + VCARD_3_URI_DEFAULTS, + VCARD_4_URI_DEFAULTS, + decodeBase64Photo, + parameterValues, + photoKind, + retainedPhotoParameters, +} from './vcardDocument.js'; +import { + additionalFieldLabel, + assertAdditionalIds, + canonicalJsonValue, + contactValue, + groupKey, + normalizedBoolean, + normalizedPhoto, + normalizedScalar, + normalizedType, + propertyUsesUriCodec, + semanticPhotoIdentity, +} from './vcardProjection.js'; + +export function localVCardEtag(vcard) { + return createHash('md5').update(vcard).digest('hex'); +} + +function normalizeSemanticRawValue(property, version) { + let value = property.rawValue.replace(/\r\n|\r/g, '\n'); + if (!propertyUsesUriCodec(property, version)) value = value.replace(/\\N/g, '\\n'); + if (property.name === 'PHOTO') { + const supportedIdentity = semanticPhotoIdentity(property, version); + if (supportedIdentity) return supportedIdentity; + const dataUri = value.match(/^data:([^,]*),(.*)$/is); + if (dataUri) { + const metadata = dataUri[1].split(';'); + const mediaType = metadata.shift().trim().toLowerCase(); + const parameters = []; + let base64 = false; + for (const entry of metadata) { + const trimmed = entry.trim(); + if (/^base64$/i.test(trimmed)) { + base64 = true; + continue; + } + const equals = trimmed.indexOf('='); + parameters.push(equals < 0 + ? trimmed.toLowerCase() + : trimmed.slice(0, equals).toLowerCase() + trimmed.slice(equals)); + } + if (base64) { + const prefix = [mediaType, ...parameters, 'base64'].filter(Boolean).join(';'); + return 'data:' + prefix + ',' + decodeBase64Photo(dataUri[2]).toString('base64'); + } + } + + const kind = photoKind(property, version); + if (kind === 'base64' || kind === 'legacy-base64') { + value = decodeBase64Photo(value).toString('base64'); + } + } + return value; +} + +function canonicalSemanticParameters(params) { + const byName = new Map(); + + for (const parameter of params) { + const name = parameter.name.toUpperCase(); + const values = byName.get(name) || []; + values.push(...parameter.values.map(value => ( + /^(?:TYPE|ENCODING|VALUE|MEDIATYPE)$/.test(name) + ? value.toLowerCase() + : value + ))); + byName.set(name, values); + } + + return [...byName] + .sort(([first], [second]) => { + if (first < second) return -1; + if (first > second) return 1; + return 0; + }) + .map(([name, values]) => ({ + name, + values: name === 'TYPE' ? [...new Set(values)].sort() : values, + })); +} + +function semanticParametersFor(property, version, photoIdentity) { + if (photoIdentity) { + return retainedPhotoParameters(property.params, { + retainImageTypeValues: version === '4.0', + }); + } + + const valueTypes = parameterValues(property, 'VALUE'); + const uriDefaults = version === '4.0' ? VCARD_4_URI_DEFAULTS : VCARD_3_URI_DEFAULTS; + if (valueTypes.length === 1 + && /^uri$/i.test(String(valueTypes[0]).trim()) + && uriDefaults.has(property.name)) { + return property.params.filter(parameter => String(parameter.name).toUpperCase() !== 'VALUE'); + } + return property.params; +} + +export function semanticVCardHash(document) { + const groups = new Map(); + let nextGroup = 1; + const canonical = { + version: document.version, + properties: document.properties + .filter(property => !SERVER_OWNED_PROPERTIES.has(property.name)) + .map(property => { + const key = groupKey(property.group); + const photoIdentity = property.name === 'PHOTO' + ? semanticPhotoIdentity(property, document.version) + : null; + const semanticParams = semanticParametersFor( + property, + document.version, + photoIdentity, + ); + if (key && !groups.has(key)) groups.set(key, `group-${nextGroup++}`); + return { + group: key ? groups.get(key) : '', + name: property.name.toUpperCase(), + params: canonicalSemanticParameters(semanticParams), + rawValue: photoIdentity || normalizeSemanticRawValue(property, document.version), + }; + }), + }; + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} + +function canonicalAdditionalField(field) { + const source = field && typeof field === 'object' ? field : {}; + const kind = normalizedScalar(source.kind).toLowerCase(); + const sourceValue = source.value && typeof source.value === 'object' + && !Array.isArray(source.value) ? source.value : {}; + let value = source.value; + if (kind === 'postal-address') { + value = Object.fromEntries(ADR_COMPONENTS.map(component => [component, sourceValue[component]])); + } else if (kind === 'im') { + value = { + protocol: normalizedScalar(sourceValue.protocol || 'im').toLowerCase(), + handle: sourceValue.handle, + }; + } + return { + id: normalizedScalar(source.id), + kind, + label: additionalFieldLabel(source, kind), + value: canonicalJsonValue(value), + }; +} + +export function localContactHash(contact) { + const emails = (contact.emails || []).map(email => ({ + value: normalizedScalar(email.value ?? email.email).trim().toLowerCase(), + type: normalizedType(email.type || 'other'), + primary: normalizedBoolean(email.primary ?? email.isPrimary ?? email.is_primary), + })); + const phones = (contact.phones || []).map(phone => ({ + value: normalizedScalar(phone.value ?? phone.number).replace(/\s/g, ''), + type: normalizedType(phone.type || 'other'), + })); + const additionalFields = contactValue(contact, 'additionalFields', 'additional_fields') || []; + assertAdditionalIds( + additionalFields, + 'MailFlow Additional field requires a stable ID', + 'MailFlow Additional field IDs must be unique', + ); + const canonical = { + uid: normalizedScalar(contact.uid), + displayName: normalizedScalar(contactValue(contact, 'displayName', 'display_name')), + firstName: normalizedScalar(contactValue(contact, 'firstName', 'first_name')), + lastName: normalizedScalar(contactValue(contact, 'lastName', 'last_name')), + emails, + phones, + organization: normalizedScalar(contact.organization), + notes: normalizedScalar(contact.notes), + photoData: normalizedPhoto(contactValue(contact, 'photoData', 'photo_data')), + additionalFields: additionalFields.map(canonicalAdditionalField), + }; + + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} diff --git a/backend/src/utils/vcardProjection.js b/backend/src/utils/vcardProjection.js new file mode 100644 index 00000000..fe7ba2f9 --- /dev/null +++ b/backend/src/utils/vcardProjection.js @@ -0,0 +1,1299 @@ +import { createHash } from 'node:crypto'; + +import { + ADR_COMPONENTS, + SERVER_OWNED_PROPERTIES, + VCARD_3_URI_DEFAULTS, + VCARD_4_URI_DEFAULTS, + canonicalSupportedPhotoDataUri, + dataUriMimeType, + decodeBase64Photo, + embeddedPhotoMimeType, + parameterValues, + parseVCardDocument, + photoKind, + retainedPhotoParameters, + serializeVCardDocument, + splitDelimited, + supportedPhotoMimeType, +} from './vcardDocument.js'; + +const ADDITIONAL_ID_PARAMETER = 'X-MAILFLOW-ID'; + +function unescapeText(value) { + let result = ''; + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (character !== '\\' || index + 1 >= value.length) { + result += character; + continue; + } + + const next = value[++index]; + if (next === 'n' || next === 'N') result += '\n'; + else if (next === '\\' || next === ';' || next === ',' || next === ':') result += next; + else result += '\\' + next; + } + return result; +} + +function escapeText(value) { + if (!value) return ''; + return String(value) + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r\n|\r|\n/g, '\\n'); +} + +function escapeParam(value) { + return String(value || '').replace(/[\r\n;:,"]/g, ''); +} + +function propertiesNamed(document, name) { + return document.properties.filter(property => property.name === name); +} + +function typesFor(property) { + return parameterValues(property, 'TYPE') + .map(value => value.trim()) + .filter(Boolean); +} + +export function groupKey(group) { + return String(group || '').toLowerCase(); +} + +export function primaryEmail(contact) { + const emails = Array.isArray(contact?.emails) ? contact.emails : []; + const primary = emails.find(email => email?.primary) ?? emails[0]; + const value = primary?.value ?? primary?.email; + return value ? String(value).toLowerCase().trim() : null; +} + +function propertyIdentityKey(property) { + return groupKey(property?.group) + '\0' + property?.name; +} + +function* propertiesWithOccurrences(document) { + const occurrences = new Map(); + for (const [index, property] of document.properties.entries()) { + const identityKey = propertyIdentityKey(property); + const occurrence = occurrences.get(identityKey) || 0; + occurrences.set(identityKey, occurrence + 1); + yield { index, property, occurrence }; + } +} + +function groupLabel(document, group) { + if (!group) return null; + const label = document.properties.find(property => ( + groupKey(property.group) === groupKey(group) && property.name === 'X-ABLABEL' + )); + if (!label) return null; + const value = unescapeText(label.rawValue); + return value.trim() ? value : null; +} + +function projectedLabel(document, property, fallback) { + return groupLabel(document, property.group) + || typesFor(property).find(type => !/^(?:internet|pref)$/i.test(type)) + || fallback; +} + +function stableAdditionalId(property, occurrence) { + const identity = JSON.stringify([groupKey(property.group), property.name, occurrence]); + return 'vcard-' + createHash('sha256').update(identity).digest('hex').slice(0, 16); +} + +function additionalId(property, occurrence) { + const identityParameters = property.params.filter(parameter => ( + String(parameter.name).toUpperCase() === ADDITIONAL_ID_PARAMETER + )); + if (identityParameters.length === 0) return stableAdditionalId(property, occurrence); + + const persisted = identityParameters.flatMap(parameter => parameter.values); + if (persisted.length !== 1 || !String(persisted[0]).trim()) { + throw new Error('vCard contains an invalid MailFlow Additional field ID'); + } + return String(persisted[0]); +} + +function hasPersistedAdditionalId(property) { + return property.params.some(parameter => ( + String(parameter.name).toUpperCase() === ADDITIONAL_ID_PARAMETER + )); +} + +function contactAdditionalId(field) { + return String(field?.id ?? ''); +} + +export function assertAdditionalIds(fields, missingError, duplicateError) { + const ids = new Set(); + for (const field of fields) { + const id = contactAdditionalId(field); + if (!id.trim()) throw new Error(missingError); + if (ids.has(id)) throw new Error(duplicateError); + ids.add(id); + } +} + +function withAdditionalId(params, id) { + const updated = []; + let written = false; + for (const parameter of params) { + if (String(parameter.name).toUpperCase() !== ADDITIONAL_ID_PARAMETER) { + updated.push(parameter); + } else if (!written) { + updated.push({ name: ADDITIONAL_ID_PARAMETER, values: [String(id ?? '')] }); + written = true; + } + } + if (!written) updated.push({ name: ADDITIONAL_ID_PARAMETER, values: [String(id ?? '')] }); + return updated; +} + +function additionalField(property, occurrence, kind, label, value) { + return { + id: additionalId(property, occurrence), + kind, + label, + value, + vcard: { + group: property.group, + name: property.name, + params: structuredClone(property.params), + }, + }; +} + +function parseImValue(rawValue, label) { + const value = String(rawValue); + const colon = value.indexOf(':'); + if (colon < 0) return { protocol: label.toLowerCase(), handle: value }; + + let protocol = value.slice(0, colon).toLowerCase(); + let handle = value.slice(colon + 1); + if (protocol === 'x-apple') { + try { + handle = decodeURIComponent(handle); + } catch { + // Keep the original handle; malformed percent encoding is opaque text. + } + protocol = label.toLowerCase() === 'im' ? 'x-apple' : label.toLowerCase(); + } + return { protocol, handle }; +} + +function parseGeoValue(rawValue) { + const value = unescapeText(rawValue).replace(/^geo:/i, ''); + const parts = value.split(/[;,]/); + if (parts.length !== 2) return null; + const latitude = Number(parts[0]); + const longitude = Number(parts[1]); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return { latitude, longitude }; +} + +function additionalFieldsFromDocument(document) { + assertUniquePersistedAdditionalIds(document); + const fields = []; + + for (const { property, occurrence } of propertiesWithOccurrences(document)) { + const raw = unescapeText(property.rawValue); + + switch (property.name) { + case 'ADR': { + const parts = splitDelimited(property.rawValue, ';').map(unescapeText); + while (parts.length < 7) parts.push(''); + fields.push(additionalField( + property, + occurrence, + 'postal-address', + projectedLabel(document, property, 'Address'), + Object.fromEntries(ADR_COMPONENTS.map((component, index) => [component, parts[index]])), + )); + break; + } + case 'URL': + fields.push(additionalField( + property, + occurrence, + 'url', + projectedLabel(document, property, 'URL'), + projectedPropertyValue(property, document.version), + )); + break; + case 'IMPP': { + const label = projectedLabel(document, property, 'IM'); + fields.push(additionalField( + property, + occurrence, + 'im', + label, + parseImValue(projectedPropertyValue(property, document.version), label), + )); + break; + } + case 'BDAY': + fields.push(additionalField( + property, occurrence, 'birthday', projectedLabel(document, property, 'Birthday'), raw, + )); + break; + case 'ANNIVERSARY': + fields.push(additionalField( + property, + occurrence, + 'anniversary', + projectedLabel(document, property, 'Anniversary'), + raw, + )); + break; + case 'X-ABDATE': + fields.push(additionalField( + property, + occurrence, + 'date', + projectedLabel(document, property, 'Date'), + raw, + )); + break; + case 'ROLE': + fields.push(additionalField( + property, occurrence, 'role', projectedLabel(document, property, 'Role'), raw, + )); + break; + case 'TITLE': + fields.push(additionalField( + property, occurrence, 'title', projectedLabel(document, property, 'Title'), raw, + )); + break; + case 'NICKNAME': + fields.push(additionalField( + property, + occurrence, + 'nickname', + projectedLabel(document, property, 'Nickname'), + raw, + )); + break; + case 'GEO': { + const value = parseGeoValue(property.rawValue); + if (value) { + fields.push(additionalField( + property, + occurrence, + 'geo', + projectedLabel(document, property, 'Location'), + value, + )); + } + break; + } + case 'X-MAILFLOW-CUSTOM': + fields.push(additionalField( + property, + occurrence, + 'custom-text', + projectedLabel(document, property, 'Custom'), + raw, + )); + break; + } + } + + assertAdditionalIds( + fields, + 'vCard contains an invalid MailFlow Additional field ID', + 'vCard contains duplicate MailFlow Additional field IDs', + ); + return fields; +} + +function photoDataFromDocument(document) { + let photoData = null; + for (const property of propertiesNamed(document, 'PHOTO')) { + const value = property.rawValue.trim(); + const kind = photoKind(property, document.version); + if (kind === 'empty') continue; + if (kind === 'url') { + photoData = null; + continue; + } + if (kind === 'data-uri') { + photoData = supportedPhotoMimeType(embeddedPhotoMimeType(property, document.version)) + ? value + : null; + continue; + } + + if (kind === 'base64') { + decodeBase64Photo(value); + const supported = supportedPhotoMimeType( + embeddedPhotoMimeType(property, document.version), + ); + photoData = supported + ? 'data:' + supported.mime + ';base64,' + value.replace(/\s/g, '') + : null; + continue; + } + + decodeBase64Photo(value); + photoData = 'data:image/jpeg;base64,' + value.replace(/\s/g, ''); + } + return photoData; +} + +function emailFromProperty(property, primary) { + const value = unescapeText(property.rawValue).trim().toLowerCase(); + if (!value) return null; + const types = typesFor(property).map(type => type.toLowerCase()); + const type = types.find(entry => entry === 'work' || entry === 'home') || 'other'; + return { value, type, primary }; +} + +function v4EmailPreference(property) { + const preferences = parameterValues(property, 'PREF') + .map(value => String(value).trim()) + .filter(value => /^\d{1,2}$|^100$/.test(value)) + .map(Number) + .filter(value => value >= 1 && value <= 100); + return preferences.length ? Math.min(...preferences) : null; +} + +function emailEntriesFromDocument(document) { + const entries = []; + document.properties.forEach((property, index) => { + if (property.name !== 'EMAIL') return; + const projected = emailFromProperty(property, false); + if (!projected) return; + entries.push({ index, property, projected }); + }); + if (entries.length === 0) return entries; + + let preferred; + if (document.version === '4.0') { + const preferences = entries.map(entry => v4EmailPreference(entry.property)); + const explicit = preferences.filter(value => value !== null); + if (explicit.length) { + const lowest = Math.min(...explicit); + preferred = preferences.map(value => value === lowest); + } + } else { + const explicit = entries.map(entry => ( + typesFor(entry.property).some(value => /^pref$/i.test(value)) + )); + if (explicit.some(Boolean)) preferred = explicit; + } + preferred ||= entries.map(() => false); + + return entries.map((entry, index) => ({ + ...entry, + projected: { ...entry.projected, primary: preferred[index] }, + })); +} + +export function propertyUsesUriCodec(property, version) { + const explicit = parameterValues(property, 'VALUE')[0]; + if (explicit !== undefined) return /^uri$/i.test(String(explicit).trim()); + return (version === '4.0' ? VCARD_4_URI_DEFAULTS : VCARD_3_URI_DEFAULTS) + .has(property.name); +} + +function projectedPropertyValue(property, version) { + return propertyUsesUriCodec(property, version) + ? String(property.rawValue) + : unescapeText(property.rawValue); +} + +function ownedPropertyRawValue(property, version, value) { + return propertyUsesUriCodec(property, version) ? normalizedScalar(value) : escapeText(value); +} + +function phoneFromProperty(property, version) { + const value = projectedPropertyValue(property, version).trim(); + if (!value) return null; + const types = typesFor(property) + .map(type => type.toLowerCase()) + .map(type => type === 'cell' ? 'mobile' : type); + const type = types.find(entry => ( + entry === 'mobile' || entry === 'work' || entry === 'home' + )) || 'other'; + return { value, type }; +} + +export function contactFromVCardDocument(document) { + const contact = { + uid: null, + displayName: null, + firstName: null, + lastName: null, + emails: emailEntriesFromDocument(document).map(entry => entry.projected), + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: additionalFieldsFromDocument(document), + }; + + for (const property of document.properties) { + switch (property.name) { + case 'UID': + contact.uid = projectedPropertyValue(property, document.version).trim() || null; + break; + case 'FN': + contact.displayName = unescapeText(property.rawValue).trim() || null; + break; + case 'N': { + const parts = splitDelimited(property.rawValue, ';') + .map(value => unescapeText(value).trim()); + contact.lastName = parts[0] || null; + contact.firstName = parts[1] || null; + break; + } + case 'EMAIL': + break; + case 'TEL': { + const phone = phoneFromProperty(property, document.version); + if (phone) contact.phones.push(phone); + break; + } + case 'ORG': + contact.organization = unescapeText( + splitDelimited(property.rawValue, ';')[0] || '', + ).trim() || null; + break; + case 'NOTE': + contact.notes = unescapeText(property.rawValue).trim() || null; + break; + } + } + + contact.photoData = photoDataFromDocument(document); + return contact; +} + +export const ADDITIONAL_PROPERTIES = new Set([ + 'ADR', + 'URL', + 'IMPP', + 'BDAY', + 'ANNIVERSARY', + 'X-ABDATE', + 'ROLE', + 'TITLE', + 'NICKNAME', + 'GEO', + 'X-MAILFLOW-CUSTOM', +]); + +export function contactValue(contact, camelName, snakeName) { + if (Object.hasOwn(contact, camelName)) return contact[camelName]; + return contact[snakeName]; +} + +function lastPropertyIndex(properties, name) { + for (let index = properties.length - 1; index >= 0; index--) { + if (properties[index].name === name) return index; + } + return -1; +} + +function structuredRawValue(property, replacements, minimumParts) { + const parts = splitDelimited(property.rawValue, ';'); + while (parts.length < minimumParts) parts.push(''); + for (const [index, value] of replacements) parts[index] = escapeText(value || ''); + return parts.join(';'); +} + +function updatedTypeParameters(property, nextType, phone) { + const ownedType = phone + ? /^(?:cell|mobile|work|home|other)$/i + : /^(?:work|home|other)$/i; + const nextValue = escapeParam(nextType || 'other').toUpperCase(); + const params = structuredClone(property.params); + let replaced = false; + + for (const parameter of params) { + if (String(parameter.name).toUpperCase() !== 'TYPE') continue; + parameter.values = parameter.values.flatMap(value => { + if (!ownedType.test(value)) return [value]; + if (replaced) return []; + replaced = true; + return [nextValue]; + }); + } + + if (!replaced) { + const parameter = params.find(entry => String(entry.name).toUpperCase() === 'TYPE'); + if (parameter) parameter.values.push(nextValue); + else params.push({ name: 'TYPE', values: [nextValue] }); + } + return params.filter(parameter => parameter.values.length > 0); +} + +function emailPrimary(email, fallback = false) { + if (Object.hasOwn(email, 'primary')) return normalizedBoolean(email.primary); + if (Object.hasOwn(email, 'isPrimary')) return normalizedBoolean(email.isPrimary); + if (Object.hasOwn(email, 'is_primary')) return normalizedBoolean(email.is_primary); + return fallback; +} + +function updatedEmailPreferenceParameters(property, version, primary) { + const params = structuredClone(property.params); + if (version === '4.0') { + const updated = []; + let written = false; + for (const parameter of params) { + if (String(parameter.name).toUpperCase() !== 'PREF') { + updated.push(parameter); + } else if (primary && !written) { + updated.push({ ...parameter, name: 'PREF', values: ['1'] }); + written = true; + } + } + if (primary && !written) updated.push({ name: 'PREF', values: ['1'] }); + return updated; + } + + let written = false; + const updated = params.map(parameter => { + if (String(parameter.name).toUpperCase() !== 'TYPE') return parameter; + const values = parameter.values.flatMap(value => { + if (!/^pref$/i.test(value)) return [value]; + if (!primary || written) return []; + written = true; + return ['PREF']; + }); + return { ...parameter, values }; + }).filter(parameter => parameter.values.length > 0); + if (primary && !written) { + const type = updated.find(parameter => String(parameter.name).toUpperCase() === 'TYPE'); + if (type) type.values.push('PREF'); + else updated.push({ name: 'TYPE', values: ['PREF'] }); + } + return updated; +} + +function additionalPropertyIndices(document) { + const indices = new Map(); + + for (const { index, property, occurrence } of propertiesWithOccurrences(document)) { + if (ADDITIONAL_PROPERTIES.has(property.name)) { + const id = additionalId(property, occurrence); + if (indices.has(id)) { + throw new Error('vCard contains duplicate MailFlow Additional field IDs'); + } + indices.set(id, index); + } + } + return indices; +} + +function assertUniquePersistedAdditionalIds(document) { + additionalPropertyIndices(document); +} + +function sameAdditionalValue(first, second) { + return JSON.stringify(canonicalJsonValue(first)) === JSON.stringify(canonicalJsonValue(second)); +} + +function retainedPhoto(property, version, hasPhoto, photoData) { + if (property.name !== 'PHOTO') return false; + if (!hasPhoto) return true; + if (photoData) return false; + return photoKind(property, version) === 'url'; +} + +function makeProperty(name, rawValue, params = [], group = null) { + return { group, name, params, rawValue }; +} + +function canonicalOwnedPhotoData(photoData) { + const value = String(photoData); + const mimeType = /^data:/i.test(value) ? dataUriMimeType(value) : null; + const supported = mimeType ? supportedPhotoMimeType(mimeType) : null; + if (/^data:/i.test(value) && !supported) { + throw new Error('vCard has unsupported PHOTO MIME type'); + } + if (/^data:/i.test(value)) return canonicalSupportedPhotoDataUri(value); + return 'data:image/jpeg;base64,' + decodeBase64Photo(value).toString('base64'); +} + +function photoProperty(version, photoData) { + const value = canonicalOwnedPhotoData(photoData); + const supported = supportedPhotoMimeType(dataUriMimeType(value)); + const inline = value.match(/^data:([^;,]+);base64,(.*)$/is); + if (version === '4.0') { + if (inline) { + return makeProperty( + 'PHOTO', + 'data:' + supported.mime + ';base64,' + inline[2], + ); + } + if (/^data:/i.test(value)) { + return makeProperty('PHOTO', value, [{ name: 'VALUE', values: ['URI'] }]); + } + return makeProperty('PHOTO', 'data:image/jpeg;base64,' + value); + } + + if (inline) { + return makeProperty('PHOTO', inline[2], [ + { name: 'ENCODING', values: ['b'] }, + { name: 'TYPE', values: [supported.type] }, + ]); + } + if (/^data:/i.test(value)) { + return makeProperty('PHOTO', value, [{ name: 'VALUE', values: ['URI'] }]); + } + return makeProperty('PHOTO', value, [ + { name: 'ENCODING', values: ['b'] }, + { name: 'TYPE', values: ['JPEG'] }, + ]); +} + +function rewrittenPhotoProperty(property, version, photoData) { + const replacement = photoProperty(version, photoData); + const retainedParams = retainedPhotoParameters(property.params); + return { + ...replacement, + group: property.group, + params: [...replacement.params, ...retainedParams], + }; +} + +export function allocateItemGroup(usedGroups) { + let index = 1; + while (usedGroups.has(('item' + index).toLowerCase())) index++; + const group = 'item' + index; + usedGroups.add(group.toLowerCase()); + return group; +} + +function additionalPropertyName(field) { + const names = { + 'postal-address': 'ADR', + url: 'URL', + im: 'IMPP', + birthday: 'BDAY', + anniversary: 'ANNIVERSARY', + date: 'X-ABDATE', + role: 'ROLE', + title: 'TITLE', + nickname: 'NICKNAME', + geo: 'GEO', + 'custom-text': 'X-MAILFLOW-CUSTOM', + }; + return names[String(field.kind || '').toLowerCase()] || null; +} + +function additionalRawValue(field, version, retainedProperty = null) { + const kind = String(field.kind || '').toLowerCase(); + if (kind === 'postal-address') { + const value = field.value || {}; + return ADR_COMPONENTS.map(component => escapeText(value[component] || '')).join(';'); + } + if (kind === 'im') { + const value = field.value || {}; + const rawValue = normalizedScalar(value.protocol || 'im').toLowerCase() + + ':' + normalizedScalar(value.handle); + return retainedProperty && !propertyUsesUriCodec(retainedProperty, version) + ? escapeText(rawValue) + : rawValue; + } + if (kind === 'url') { + const rawValue = normalizedScalar(field.value); + return retainedProperty && !propertyUsesUriCodec(retainedProperty, version) + ? escapeText(rawValue) + : rawValue; + } + if (kind === 'geo') { + const value = field.value || {}; + if (version === '3.0') return value.latitude + ';' + value.longitude; + return 'geo:' + value.latitude + ',' + value.longitude; + } + return escapeText(field.value || ''); +} + +export function additionalFieldLabel(field, kind) { + const label = normalizedScalar(field?.label); + if (label.trim()) return label; + // A canonical/typed field maps to a real vCard property (BDAY, ADR, URL, …) and + // only needs a label for optional X-ABLABEL grouping, so a blank label is valid. + // A custom-text field carries its meaning solely in the label and still requires one. + if (kind === 'custom-text') { + throw new Error('MailFlow custom text field requires a label'); + } + return ''; +} + +function additionalProperties(fields, usedGroups, version) { + const properties = []; + const labeledGroups = new Set(); + + for (const field of fields) { + const kind = String(field.kind || '').toLowerCase(); + const label = additionalFieldLabel(field, kind); + const metadata = field.vcard || {}; + const name = additionalPropertyName(field); + if (!name) continue; + + const compatibleMetadata = String(metadata.name || '').toUpperCase() === name; + const group = kind === 'custom-text' || label ? allocateItemGroup(usedGroups) : null; + const retainedParams = compatibleMetadata && Array.isArray(metadata.params) + ? structuredClone(metadata.params) + : []; + const params = withAdditionalId(retainedParams, field.id); + properties.push(makeProperty(name, additionalRawValue(field, version), params, group)); + + const groupKey = String(group).toLowerCase(); + if (group && label && !labeledGroups.has(groupKey)) { + properties.push(makeProperty('X-ABLABEL', escapeText(label), [], group)); + labeledGroups.add(groupKey); + } + } + + return properties; +} + +export function overlayContactOnVCard(document, contact, { preserveDocumentUid = false } = {}) { + const source = { + version: document?.version || '3.0', + properties: structuredClone(document?.properties || []), + }; + const sourceContact = contactFromVCardDocument(source); + const hasPhoto = Object.hasOwn(contact, 'photoData') || Object.hasOwn(contact, 'photo_data'); + const photoData = contactValue(contact, 'photoData', 'photo_data'); + const hasAdditional = Object.hasOwn(contact, 'additionalFields') + || Object.hasOwn(contact, 'additional_fields'); + const additionalFields = contactValue(contact, 'additionalFields', 'additional_fields') || []; + if (hasAdditional) { + assertAdditionalIds( + additionalFields, + 'MailFlow Additional field requires a stable ID', + 'MailFlow Additional field IDs must be unique', + ); + } + const uid = contactValue(contact, 'uid', 'uid'); + const displayName = contactValue(contact, 'displayName', 'display_name'); + const firstName = contactValue(contact, 'firstName', 'first_name'); + const lastName = contactValue(contact, 'lastName', 'last_name'); + const emails = contact.emails || []; + const phones = contact.phones || []; + const organization = contact.organization; + const notes = contact.notes; + const replacements = new Map(); + const appendedCore = []; + const appendedAdditional = []; + const groupsToClean = new Set(); + const labelUpdates = new Map(); + const usedGroups = new Set( + source.properties.filter(property => property.group) + .map(property => property.group.toLowerCase()), + ); + + const removeProperty = index => { + replacements.set(index, []); + const group = source.properties[index].group; + if (group) groupsToClean.add(groupKey(group)); + }; + const replaceScalar = (name, sourceValue, nextValue, rawValue) => { + const index = lastPropertyIndex(source.properties, name); + const property = index >= 0 ? source.properties[index] : makeProperty(name, ''); + const nextRawValue = typeof rawValue === 'function' ? rawValue(property) : rawValue; + if (index >= 0) { + if (normalizedScalar(sourceValue) !== normalizedScalar(nextValue)) { + replacements.set(index, [{ ...source.properties[index], rawValue: nextRawValue }]); + } + } else if (normalizedScalar(nextValue)) { + appendedCore.push(makeProperty(name, nextRawValue)); + } + }; + + // UID is remote-owned identity. On an outgoing edit of an existing remote resource + // (preserveDocumentUid) keep the retained document's UID so Mailflow never rewrites + // it on the server; only mint from the contact when the document has no UID (create) + // or when the caller wants the local key on the document (presented/local vCards). + if (!(preserveDocumentUid && sourceContact.uid)) { + replaceScalar('UID', sourceContact.uid, uid, property => ( + ownedPropertyRawValue(property, source.version, uid || '') + )); + } + replaceScalar('FN', sourceContact.displayName, displayName, escapeText(displayName || '')); + + const nameIndex = lastPropertyIndex(source.properties, 'N'); + if (nameIndex >= 0) { + if (normalizedScalar(sourceContact.lastName) !== normalizedScalar(lastName) + || normalizedScalar(sourceContact.firstName) !== normalizedScalar(firstName)) { + replacements.set(nameIndex, [{ + ...source.properties[nameIndex], + rawValue: structuredRawValue( + source.properties[nameIndex], + [[0, lastName], [1, firstName]], + 5, + ), + }]); + } + } else if (firstName || lastName) { + appendedCore.push(makeProperty( + 'N', + escapeText(lastName || '') + ';' + escapeText(firstName || '') + ';;;', + )); + } + + const sourceEmails = emailEntriesFromDocument(source); + const sourcePhones = []; + source.properties.forEach((property, index) => { + if (property.name === 'TEL') { + const projected = phoneFromProperty(property, source.version); + if (projected) sourcePhones.push({ index, projected }); + } + }); + const normalizeEmailPreferences = emails.length !== sourceEmails.length + || sourceEmails.some(({ projected }, index) => ( + !emails[index] || emailPrimary(emails[index], projected.primary) !== projected.primary + )); + + sourceEmails.forEach(({ index, projected }, emailIndex) => { + const next = emails[emailIndex]; + if (!next) { + removeProperty(index); + return; + } + const property = structuredClone(source.properties[index]); + const nextValue = next.value ?? next.email ?? ''; + const nextType = String(next.type || 'other').toLowerCase(); + const nextPrimary = emailPrimary(next, projected.primary); + let changed = false; + if (String(nextValue).trim().toLowerCase() !== projected.value) { + property.rawValue = escapeText(nextValue); + changed = true; + } + if (nextType !== projected.type) { + property.params = updatedTypeParameters(property, nextType, false); + changed = true; + } + if (normalizeEmailPreferences) { + property.params = updatedEmailPreferenceParameters(property, source.version, nextPrimary); + changed = true; + } + if (changed) replacements.set(index, [property]); + }); + for (const email of emails.slice(sourceEmails.length)) { + const property = makeProperty( + 'EMAIL', + escapeText(email.value ?? email.email ?? ''), + [{ name: 'TYPE', values: [escapeParam(email.type || 'other').toUpperCase()] }], + ); + property.params = updatedEmailPreferenceParameters( + property, + source.version, + emailPrimary(email), + ); + appendedCore.push(property); + } + + sourcePhones.forEach(({ index, projected }, phoneIndex) => { + const next = phones[phoneIndex]; + if (!next) { + removeProperty(index); + return; + } + const property = structuredClone(source.properties[index]); + const nextValue = next.value ?? next.number ?? ''; + const nextType = normalizedType(next.type || 'other'); + let changed = false; + if (String(nextValue).trim() !== projected.value) { + property.rawValue = ownedPropertyRawValue(property, source.version, nextValue); + changed = true; + } + if (nextType !== projected.type) { + property.params = updatedTypeParameters(property, nextType, true); + changed = true; + } + if (changed) replacements.set(index, [property]); + }); + for (const phone of phones.slice(sourcePhones.length)) { + const property = makeProperty( + 'TEL', + '', + [{ name: 'TYPE', values: [escapeParam(phone.type || 'voice').toUpperCase()] }], + ); + property.rawValue = ownedPropertyRawValue( + property, + source.version, + phone.value ?? phone.number ?? '', + ); + appendedCore.push(property); + } + + const organizationIndex = lastPropertyIndex(source.properties, 'ORG'); + if (organizationIndex >= 0) { + if (normalizedScalar(sourceContact.organization) !== normalizedScalar(organization)) { + replacements.set(organizationIndex, [{ + ...source.properties[organizationIndex], + rawValue: structuredRawValue(source.properties[organizationIndex], [[0, organization]], 1), + }]); + } + } else if (organization) { + appendedCore.push(makeProperty('ORG', escapeText(organization))); + } + replaceScalar('NOTE', sourceContact.notes, notes, escapeText(notes || '')); + + if (hasAdditional) { + const propertyIndices = additionalPropertyIndices(source); + const sourceFields = new Map(sourceContact.additionalFields.map(field => [field.id, field])); + const targetById = new Map(additionalFields.map(field => [contactAdditionalId(field), field])); + const sourceIndices = sourceContact.additionalFields + .map(field => propertyIndices.get(field.id)) + .sort((first, second) => first - second); + + const sourceOrderByIdentity = new Map(); + for (const field of sourceContact.additionalFields) { + const propertyIndex = propertyIndices.get(field.id); + if (hasPersistedAdditionalId(source.properties[propertyIndex])) continue; + const key = propertyIdentityKey(field.vcard); + const ids = sourceOrderByIdentity.get(key) || []; + ids.push(field.id); + sourceOrderByIdentity.set(key, ids); + } + for (const ids of sourceOrderByIdentity.values()) { + if (ids.length < 2) continue; + const targetIds = new Set(additionalFields.map(contactAdditionalId)); + let deletedEarlier = false; + for (const id of ids) { + if (!targetIds.has(id)) { + deletedEarlier = true; + } else if (deletedEarlier) { + throw new Error( + 'MailFlow Additional fields cannot delete an earlier ambiguous retained property', + ); + } + } + const positions = new Map(ids.map((id, index) => [id, index])); + const targetOrder = additionalFields + .map(field => positions.get(contactAdditionalId(field))) + .filter(position => position !== undefined); + if (targetOrder.some((position, index) => index > 0 && position < targetOrder[index - 1])) { + throw new Error( + 'MailFlow Additional fields cannot reorder ambiguous retained properties', + ); + } + } + + const canUpdateSharedLabel = (sourceField, nextLabel) => { + const key = groupKey(sourceField.vcard?.group); + if (!key) return false; + const groupedProperties = source.properties.filter(property => ( + groupKey(property.group) === key && property.name !== 'X-ABLABEL' + )); + const groupedFields = sourceContact.additionalFields.filter(field => ( + groupKey(field.vcard?.group) === key + )); + if (groupedProperties.length !== groupedFields.length) return false; + return groupedFields.every(field => { + const target = targetById.get(String(field.id || '')); + return target + && additionalPropertyName(target) === field.vcard.name + && normalizedScalar(target.label) === nextLabel; + }); + }; + + const derivedIdsToPersist = new Set(); + const projectedSourceIndices = new Set(sourceIndices); + const targetRowIndices = new Map(); + for (const targetField of additionalFields) { + if (additionalPropertyName(targetField)) { + targetRowIndices.set(contactAdditionalId(targetField), targetRowIndices.size); + } + } + const sourceFieldsByIdentity = new Map(); + for (const sourceField of sourceContact.additionalFields) { + const key = propertyIdentityKey(sourceField.vcard); + const fields = sourceFieldsByIdentity.get(key) || []; + fields.push(sourceField); + sourceFieldsByIdentity.set(key, fields); + } + for (const [identityKey, sourceIdentityFields] of sourceFieldsByIdentity) { + const retainedTargetFields = additionalFields.flatMap(field => { + const sourceField = sourceFields.get(contactAdditionalId(field)); + if (!sourceField) return []; + const sourceKey = propertyIdentityKey(sourceField.vcard); + if (sourceKey !== identityKey || additionalPropertyName(field) !== sourceField.vcard.name) { + return []; + } + const nextLabel = additionalFieldLabel(field, String(field.kind || '').toLowerCase()); + if (normalizedScalar(sourceField.label) !== nextLabel + && !canUpdateSharedLabel(sourceField, nextLabel)) { + return []; + } + return [{ sourceField, targetRowIndex: targetRowIndices.get(contactAdditionalId(field)) }]; + }); + + for (const sourceField of sourceIdentityFields) { + const propertyIndex = propertyIndices.get(sourceField.id); + const retainedProperty = source.properties[propertyIndex]; + if (hasPersistedAdditionalId(retainedProperty)) continue; + const targetOccurrenceIndex = retainedTargetFields.findIndex( + retained => retained.sourceField.id === sourceField.id, + ); + if (targetOccurrenceIndex < 0) continue; + const targetSlot = sourceIndices[ + retainedTargetFields[targetOccurrenceIndex].targetRowIndex + ] ?? Infinity; + const opaquePredecessors = source.properties.filter((property, index) => ( + index < targetSlot + && !projectedSourceIndices.has(index) + && propertyIdentityKey(property) === identityKey + )).length; + const targetOccurrence = targetOccurrenceIndex + opaquePredecessors; + if (stableAdditionalId(retainedProperty, targetOccurrence) !== sourceField.id) { + derivedIdsToPersist.add(sourceField.id); + } + } + } + + const targetRows = additionalFields.flatMap(field => { + const kind = String(field.kind || '').toLowerCase(); + const nextLabel = additionalFieldLabel(field, kind); + const id = contactAdditionalId(field); + const sourceField = sourceFields.get(id); + const propertyIndex = sourceField ? propertyIndices.get(id) : null; + const retainedProperty = propertyIndex == null ? null : source.properties[propertyIndex]; + const name = additionalPropertyName(field); + if (!name) return []; + if (!sourceField || retainedProperty.name !== name) { + return [additionalProperties([field], usedGroups, source.version)]; + } + + const property = structuredClone(retainedProperty); + if (derivedIdsToPersist.has(id)) { + property.params = withAdditionalId(property.params, id); + } + if (!sameAdditionalValue(field.value, sourceField.value)) { + property.rawValue = additionalRawValue(field, source.version, retainedProperty); + } + const sourceLabel = normalizedScalar(sourceField.label); + if (sourceLabel === nextLabel) return [[property]]; + + if (canUpdateSharedLabel(sourceField, nextLabel)) { + const key = groupKey(property.group); + if (!labelUpdates.has(key)) { + labelUpdates.set(key, { + group: property.group, + rawValue: nextLabel ? escapeText(nextLabel) : null, + remove: !nextLabel, + }); + } + return [[property]]; + } + + // A cleared label drops the custom X-ABLABEL: keep the property but leave it + // ungrouped so it projects back to its default (type/kind) label. + if (!nextLabel) { + property.group = null; + return [[property]]; + } + + property.group = allocateItemGroup(usedGroups); + property.params = withAdditionalId(property.params, id); + return [[ + property, + makeProperty('X-ABLABEL', escapeText(nextLabel), [], property.group), + ]]; + }); + + sourceIndices.forEach((propertyIndex, index) => { + const group = source.properties[propertyIndex].group; + if (group) groupsToClean.add(groupKey(group)); + replacements.set(propertyIndex, targetRows[index] || []); + }); + for (const row of targetRows.slice(sourceIndices.length)) { + appendedAdditional.push(...row); + } + } + + let appendedPhoto = []; + if (hasPhoto && normalizedPhoto(photoData) !== normalizedPhoto(sourceContact.photoData)) { + let rewrittenPhotoIndex = -1; + if (photoData && !/^https?:\/\//i.test(photoData) && sourceContact.photoData) { + const sourcePhotoIdentity = normalizedPhoto(sourceContact.photoData); + for (let index = source.properties.length - 1; index >= 0; index--) { + const property = source.properties[index]; + if (property.name === 'PHOTO' + && semanticPhotoIdentity(property, source.version) === sourcePhotoIdentity) { + rewrittenPhotoIndex = index; + replacements.set(index, [rewrittenPhotoProperty(property, source.version, photoData)]); + break; + } + } + } + source.properties.forEach((property, index) => { + if (index !== rewrittenPhotoIndex + && property.name === 'PHOTO' + && !retainedPhoto(property, source.version, hasPhoto, photoData)) { + removeProperty(index); + } + }); + if (rewrittenPhotoIndex < 0 && photoData && !/^https?:\/\//i.test(photoData)) { + appendedPhoto = [photoProperty(source.version, photoData)]; + } + } + + let properties = []; + source.properties.forEach((property, index) => { + if (SERVER_OWNED_PROPERTIES.has(property.name)) return; + properties.push(...(replacements.get(index) || [property])); + }); + properties.push(...appendedCore, ...appendedPhoto, ...appendedAdditional); + + const updatedLabels = new Set(); + properties = properties.flatMap(property => { + if (property.name !== 'X-ABLABEL') return [property]; + const key = groupKey(property.group); + const update = labelUpdates.get(key); + if (!update || updatedLabels.has(key)) return [property]; + updatedLabels.add(key); + if (update.remove) return []; + return [{ ...property, group: update.group, rawValue: update.rawValue }]; + }); + for (const [key, update] of labelUpdates) { + if (!updatedLabels.has(key) && !update.remove) { + properties.push(makeProperty('X-ABLABEL', update.rawValue, [], update.group)); + } + } + for (const key of groupsToClean) { + const hasGroupedProperty = properties.some(property => ( + property.name !== 'X-ABLABEL' && groupKey(property.group) === key + )); + if (!hasGroupedProperty) { + properties = properties.filter(property => !( + property.name === 'X-ABLABEL' && groupKey(property.group) === key + )); + } + } + + return { + version: source.version, + properties, + }; +} + +// Replace a document's UID property with `uidProperty` (dropping duplicate UID lines), +// preserving the given UID's exact encoding. A plain array transform that does not throw. +export function withDocumentUid(document, uidProperty) { + if (!uidProperty) return document; + let replaced = false; + const properties = document.properties.flatMap(property => { + if (property.name !== 'UID') return [property]; + if (replaced) return []; + replaced = true; + return [structuredClone(uidProperty)]; + }); + if (!replaced) properties.unshift(structuredClone(uidProperty)); + return { ...document, properties }; +} + +// A push-destined snapshot must NEVER emit a local UID upstream. When the overlay +// throws (e.g. assertAdditionalIds on malformed Additional-field IDs), re-key the stored +// vCard to the retained document's remote UID via transforms that cannot throw; if that is +// impossible (no retained UID, or the stored vCard is unparseable), fail CLOSED by +// re-throwing so the conflict/recovery machinery surfaces the error rather than pushing a +// wrong UID. Never falls back to the raw local-UID document. +export function pushSafeSnapshot(rawVcard, retainedDocument, cause) { + const retainedUidProperty = retainedDocument?.properties?.find(property => property.name === 'UID'); + if (retainedUidProperty && typeof rawVcard === 'string') { + try { + const rekeyed = serializeVCardDocument(withDocumentUid(parseVCardDocument(rawVcard), retainedUidProperty)); + console.warn('[carddav] presented overlay failed; pushed re-keyed fallback snapshot'); + return rekeyed; + } catch { + // fall through to fail closed + } + } + throw cause; +} + +// preserveDocumentUid: false (default) keeps the LOCAL UID that keys the resource URL — +// what the CardDAV server serves; an overlay failure falls back to the stored vCard so a +// read never fails. true keeps the retained REMOTE UID — for a conflict snapshot that +// keep-mailflow pushes verbatim, where the remote resource's UID must never be rewritten; +// an overlay failure re-keys or fails closed via pushSafeSnapshot, never +// emitting the local-UID document. Do not conflate the two usages. +// +// The document MailFlow's own CardDAV server serves to native clients. For a mapped +// contact it overlays the local modeled columns onto the retained lossless remote +// vCard so unmodeled server properties (CATEGORIES, KEY, TZ, X-*) are visible to and +// round-trip through the client. Unmapped/local contacts serve their stored vCard, and +// any overlay failure falls back to it so a read never fails. `contact` is a raw contacts +// row (snake_case columns + parsed jsonb emails/phones); overlayContactOnVCard reads both +// column casings. +export function presentedVCard(contact, { preserveDocumentUid = false } = {}) { + if (!contact.mapping_vcard) return contact.vcard; + let retained; + try { + retained = parseVCardDocument(contact.mapping_vcard); + return serializeVCardDocument(overlayContactOnVCard(retained, contact, { preserveDocumentUid })); + } catch (error) { + if (!preserveDocumentUid) return contact.vcard; + return pushSafeSnapshot(contact.vcard, retained, error); + } +} + +// The strong ETag MailFlow's CardDAV server serves for a contact, derived from the +// PRESENTED representation rather than contacts.etag. This advances whenever the +// served bytes change — including a remote-only edit to unmodeled properties that leaves +// the modeled columns (and contacts.etag) untouched — so a stale If-Match is rejected and +// getctag pollers that re-fetch see fresh bytes under a fresh ETag. +export function presentedEtag(contact) { + return createHash('md5').update(presentedVCard(contact)).digest('hex'); +} + +export function semanticPhotoIdentity(property, version) { + const supported = supportedPhotoMimeType(embeddedPhotoMimeType(property, version)); + if (!supported) return null; + const kind = photoKind(property, version); + if (kind === 'data-uri') return canonicalSupportedPhotoDataUri(property.rawValue); + if (kind === 'base64' || kind === 'legacy-base64') { + const bytes = decodeBase64Photo(property.rawValue); + return 'data:' + supported.mime + ';base64,' + bytes.toString('base64'); + } + return null; +} + +export function normalizedScalar(value) { + if (value == null || value === '') return ''; + return String(value).replace(/\r\n|\r/g, '\n'); +} + +export function normalizedType(value) { + const type = normalizedScalar(value).trim().toLowerCase(); + return type === 'cell' ? 'mobile' : type; +} + +export function normalizedBoolean(value) { + return value === true || value === 1 || String(value).toLowerCase() === 'true'; +} + +export function normalizedPhoto(value) { + const photo = normalizedScalar(value); + const canonical = canonicalSupportedPhotoDataUri(photo); + if (canonical || !photo || /^data:|^https?:\/\//i.test(photo)) return canonical || photo; + return 'data:image/jpeg;base64,' + decodeBase64Photo(photo).toString('base64'); +} + +export function canonicalJsonValue(value, key = '') { + if (value == null) return ''; + if (Array.isArray(value)) return value.map(entry => canonicalJsonValue(entry)); + if (typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map(name => [name, canonicalJsonValue(value[name], name)]), + ); + } + if (typeof value === 'string') { + const normalized = normalizedScalar(value); + return key === 'kind' || key === 'type' ? normalized.toLowerCase() : normalized; + } + return value; +} diff --git a/backend/src/utils/vcardProperties.js b/backend/src/utils/vcardProperties.js new file mode 100644 index 00000000..2b5a9141 --- /dev/null +++ b/backend/src/utils/vcardProperties.js @@ -0,0 +1,31 @@ +/** + * Public vCard API surface. + * + * Implementation lives in vcardDocument, vcardProjection, and vcardHashes. + */ +// Retained-document syntax and the shared server-owned property boundary. +export { + ADR_COMPONENTS, + SERVER_OWNED_PROPERTIES, + parseVCardDocument, + serializeVCardDocument, +} from './vcardDocument.js'; +// Contact projection and retained-document overlay. +export { + ADDITIONAL_PROPERTIES, + allocateItemGroup, + contactFromVCardDocument, + groupKey, + overlayContactOnVCard, + presentedEtag, + presentedVCard, + primaryEmail, + pushSafeSnapshot, + withDocumentUid, +} from './vcardProjection.js'; +// Remote-document semantic and local-contact hashes. +export { + localContactHash, + localVCardEtag, + semanticVCardHash, +} from './vcardHashes.js'; diff --git a/backend/src/utils/vcardProperties.test.js b/backend/src/utils/vcardProperties.test.js new file mode 100644 index 00000000..f61959a5 --- /dev/null +++ b/backend/src/utils/vcardProperties.test.js @@ -0,0 +1,3194 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + contactFromVCardDocument, + localContactHash, + overlayContactOnVCard, + parseVCardDocument, + presentedEtag, + presentedVCard, + pushSafeSnapshot, + semanticVCardHash, + serializeVCardDocument, +} from './vcardProperties.js'; + +const MAX_VCARD_BYTES = 1024 * 1024; +const MAX_PROPERTIES = 2000; +const MAX_PARAMETERS = 64; +const MAX_CONTENT_LINE_BYTES = 64 * 1024; +const MAX_PHOTO_BYTES = 512 * 1024; + +function vcard(lines, version = '3.0') { + return ['BEGIN:VCARD', `VERSION:${version}`, ...lines, 'END:VCARD', ''].join('\r\n'); +} + +function parameterValuesForTest(property, name) { + return property.params + .filter(parameter => parameter.name === name) + .flatMap(parameter => parameter.values); +} + +function asciiVCardOfSize(size) { + const start = 'BEGIN:VCARD\r\nVERSION:3.0\r\n'; + const end = 'END:VCARD\r\n'; + let remaining = size - Buffer.byteLength(start) - Buffer.byteLength(end); + const lines = []; + + while (remaining > 0) { + let lineBytes = Math.min(MAX_CONTENT_LINE_BYTES + 2, remaining); + const tail = remaining - lineBytes; + if (tail > 0 && tail < 4) lineBytes -= 4 - tail; + lines.push(`X:${'a'.repeat(lineBytes - 4)}\r\n`); + remaining -= lineBytes; + } + + return start + lines.join('') + end; +} + +function foldAsciiLine(line) { + const parts = []; + let offset = 0; + let first = true; + while (offset < line.length) { + const width = first ? 75 : 74; + parts.push(`${first ? '' : ' '}${line.slice(offset, offset + width)}`); + offset += width; + first = false; + } + return parts.join('\r\n'); +} + +function additionalIdentityRows(contact) { + return contact.additionalFields.map(({ id, kind, label, value }) => ({ + id, + kind, + label, + value, + })); +} + +// Adapted from Nametag's Apple vCard fixture, with MailFlow-specific grouped +// parameter cases added. Source pinned to: +// https://github.com/mattogodoy/nametag/blob/bdbe7c129f600b90277a516066f78a731e67e017/tests/vcards/apple-vcard-example.vcf +const NAMETAG_DERIVED_FIXTURE = vcard([ + 'PRODID:-//Apple Inc.//iPhone OS 26.2//EN', + 'N:Brown;Emmetiano;Lucas;;', + 'FN:Emmetiano Lucas Brown', + 'item1.TEL;TYPE="CELL,VOICE";X-ABLABEL=Mobile:+15551234567', + 'EMAIL;TYPE=INTERNET;TYPE=WORK:doc@bttf.com', + 'item2.ADR;TYPE=WORK:;;Rio Segre 2;Valdemorillo;Madrid;28210;Spain', + 'item2.X-ABADR:es', + 'item3.URL;TYPE=pref:biffsucks.com', + 'item3.X-ABLabel:Hobbies', + 'X-SOCIALPROFILE;TYPE=twitter:http://twitter.com/doc', + 'NOTE:Escaped comma\\, semicolon\\; newline\\nand slash\\\\ retained', + 'X-MAILFLOW-UNKNOWN;X-LABEL="one;two":opaque\\:value:tail', + 'EXPERTISE;X-LEVEL=9:Flux dynamics', + 'IMPP;TYPE=HOME:xmpp:doc@example.test', +]); + +const VCARD_4_BEHAVIOR_FIXTURE = vcard([ + 'UID:urn:uuid:contact-4', + 'FN:Jane Q. Doe', + 'N:Doe;Jane;Q.;;', + 'EMAIL;TYPE=WORK;PREF=1:Jane.Doe@Example.Test', + 'TEL;VALUE=uri;TYPE=CELL:tel:+15551234567;ext=9', + 'PHOTO:data:image/png;base64,AQID', + 'X-REMOTE;X-ORDER=one,two:opaque', +], '4.0'); + +const FOLDED_PHOTO_BEHAVIOR_FIXTURE = vcard([ + 'UID:folded-photo', + 'FN:Folded Photo', + 'PHOTO;ENCODING=b;TYPE=PNG:AQ\r\n ID', +]); + +function behaviorContract(raw) { + const document = parseVCardDocument(raw); + const projection = contactFromVCardDocument(document); + const serialized = serializeVCardDocument(document); + + return { + serialized, + serializedBytes: Buffer.from(serialized).toString('base64'), + projection, + semanticHash: semanticVCardHash(document), + localHash: localContactHash(projection), + }; +} + +describe('vCard parsing, projection, serialization, and hashing behavior contracts', () => { + it('captures vCard 3.0 escaping, Additional fields, and custom properties', () => { + expect(behaviorContract(NAMETAG_DERIVED_FIXTURE)).toMatchInlineSnapshot(` + { + "localHash": "55b28825db91fb008c76cc907842fcce1e530ea21d5075ba053ac83f8d15ec56", + "projection": { + "additionalFields": [ + { + "id": "vcard-ef137e5c921d3ae9", + "kind": "postal-address", + "label": "WORK", + "value": { + "country": "Spain", + "extendedAddress": "", + "locality": "Valdemorillo", + "poBox": "", + "postalCode": "28210", + "region": "Madrid", + "street": "Rio Segre 2", + }, + "vcard": { + "group": "item2", + "name": "ADR", + "params": [ + { + "name": "TYPE", + "values": [ + "WORK", + ], + }, + ], + }, + }, + { + "id": "vcard-8510414f13db4465", + "kind": "url", + "label": "Hobbies", + "value": "biffsucks.com", + "vcard": { + "group": "item3", + "name": "URL", + "params": [ + { + "name": "TYPE", + "values": [ + "pref", + ], + }, + ], + }, + }, + { + "id": "vcard-67b665a82e7253f8", + "kind": "im", + "label": "HOME", + "value": { + "handle": "doc@example.test", + "protocol": "xmpp", + }, + "vcard": { + "group": null, + "name": "IMPP", + "params": [ + { + "name": "TYPE", + "values": [ + "HOME", + ], + }, + ], + }, + }, + ], + "displayName": "Emmetiano Lucas Brown", + "emails": [ + { + "primary": false, + "type": "work", + "value": "doc@bttf.com", + }, + ], + "firstName": "Emmetiano", + "lastName": "Brown", + "notes": "Escaped comma, semicolon; newline + and slash\\ retained", + "organization": null, + "phones": [ + { + "type": "mobile", + "value": "+15551234567", + }, + ], + "photoData": null, + "uid": null, + }, + "semanticHash": "0243c83dda67df0fb9b2409c053036e3330e79c2edcbcd98e05b217fe2adc683", + "serialized": "BEGIN:VCARD + VERSION:3.0 + PRODID:-//Apple Inc.//iPhone OS 26.2//EN + N:Brown;Emmetiano;Lucas;; + FN:Emmetiano Lucas Brown + item1.TEL;TYPE=CELL,VOICE;X-ABLABEL=Mobile:+15551234567 + EMAIL;TYPE=INTERNET;TYPE=WORK:doc@bttf.com + item2.ADR;TYPE=WORK:;;Rio Segre 2;Valdemorillo;Madrid;28210;Spain + item2.X-ABADR:es + item3.URL;TYPE=pref:biffsucks.com + item3.X-ABLABEL:Hobbies + X-SOCIALPROFILE;TYPE=twitter:http://twitter.com/doc + NOTE:Escaped comma\\, semicolon\\; newline\\nand slash\\\\ retained + X-MAILFLOW-UNKNOWN;X-LABEL="one;two":opaque\\:value:tail + EXPERTISE;X-LEVEL=9:Flux dynamics + IMPP;TYPE=HOME:xmpp:doc@example.test + END:VCARD + ", + "serializedBytes": "QkVHSU46VkNBUkQNClZFUlNJT046My4wDQpQUk9ESUQ6LS8vQXBwbGUgSW5jLi8vaVBob25lIE9TIDI2LjIvL0VODQpOOkJyb3duO0VtbWV0aWFubztMdWNhczs7DQpGTjpFbW1ldGlhbm8gTHVjYXMgQnJvd24NCml0ZW0xLlRFTDtUWVBFPUNFTEwsVk9JQ0U7WC1BQkxBQkVMPU1vYmlsZTorMTU1NTEyMzQ1NjcNCkVNQUlMO1RZUEU9SU5URVJORVQ7VFlQRT1XT1JLOmRvY0BidHRmLmNvbQ0KaXRlbTIuQURSO1RZUEU9V09SSzo7O1JpbyBTZWdyZSAyO1ZhbGRlbW9yaWxsbztNYWRyaWQ7MjgyMTA7U3BhaW4NCml0ZW0yLlgtQUJBRFI6ZXMNCml0ZW0zLlVSTDtUWVBFPXByZWY6YmlmZnN1Y2tzLmNvbQ0KaXRlbTMuWC1BQkxBQkVMOkhvYmJpZXMNClgtU09DSUFMUFJPRklMRTtUWVBFPXR3aXR0ZXI6aHR0cDovL3R3aXR0ZXIuY29tL2RvYw0KTk9URTpFc2NhcGVkIGNvbW1hXCwgc2VtaWNvbG9uXDsgbmV3bGluZVxuYW5kIHNsYXNoXFwgcmV0YWluZWQNClgtTUFJTEZMT1ctVU5LTk9XTjtYLUxBQkVMPSJvbmU7dHdvIjpvcGFxdWVcOnZhbHVlOnRhaWwNCkVYUEVSVElTRTtYLUxFVkVMPTk6Rmx1eCBkeW5hbWljcw0KSU1QUDtUWVBFPUhPTUU6eG1wcDpkb2NAZXhhbXBsZS50ZXN0DQpFTkQ6VkNBUkQNCg==", + } + `); + }); + + it('captures vCard 4.0 syntax, projection, and hashes', () => { + expect(behaviorContract(VCARD_4_BEHAVIOR_FIXTURE)).toMatchInlineSnapshot(` + { + "localHash": "4c59c578e03979fb2ed5c199bff36596ba5ffdfb58e0c93f320601803cff0c24", + "projection": { + "additionalFields": [], + "displayName": "Jane Q. Doe", + "emails": [ + { + "primary": true, + "type": "work", + "value": "jane.doe@example.test", + }, + ], + "firstName": "Jane", + "lastName": "Doe", + "notes": null, + "organization": null, + "phones": [ + { + "type": "mobile", + "value": "tel:+15551234567;ext=9", + }, + ], + "photoData": "data:image/png;base64,AQID", + "uid": "urn:uuid:contact-4", + }, + "semanticHash": "05fb7f95e67393f76c9b69c0b96de19042d15a5636ef14fb26fb8255bbda835f", + "serialized": "BEGIN:VCARD + VERSION:4.0 + UID:urn:uuid:contact-4 + FN:Jane Q. Doe + N:Doe;Jane;Q.;; + EMAIL;TYPE=WORK;PREF=1:Jane.Doe@Example.Test + TEL;VALUE=uri;TYPE=CELL:tel:+15551234567;ext=9 + PHOTO:data:image/png;base64,AQID + X-REMOTE;X-ORDER=one,two:opaque + END:VCARD + ", + "serializedBytes": "QkVHSU46VkNBUkQNClZFUlNJT046NC4wDQpVSUQ6dXJuOnV1aWQ6Y29udGFjdC00DQpGTjpKYW5lIFEuIERvZQ0KTjpEb2U7SmFuZTtRLjs7DQpFTUFJTDtUWVBFPVdPUks7UFJFRj0xOkphbmUuRG9lQEV4YW1wbGUuVGVzdA0KVEVMO1ZBTFVFPXVyaTtUWVBFPUNFTEw6dGVsOisxNTU1MTIzNDU2NztleHQ9OQ0KUEhPVE86ZGF0YTppbWFnZS9wbmc7YmFzZTY0LEFRSUQNClgtUkVNT1RFO1gtT1JERVI9b25lLHR3bzpvcGFxdWUNCkVORDpWQ0FSRA0K", + } + `); + }); + + it('captures folded PHOTO bytes, projection, and hashes', () => { + expect(behaviorContract(FOLDED_PHOTO_BEHAVIOR_FIXTURE)).toMatchInlineSnapshot(` + { + "localHash": "91da1d7ca4d830d5f40e0031eefe205b046431a0d8e12e76f541d0c7e4d5b2ed", + "projection": { + "additionalFields": [], + "displayName": "Folded Photo", + "emails": [], + "firstName": null, + "lastName": null, + "notes": null, + "organization": null, + "phones": [], + "photoData": "data:image/png;base64,AQID", + "uid": "folded-photo", + }, + "semanticHash": "c6cc7538ba6dc19e5d1ba70058ef864a5e9c440635697895051dd3d7e0df0c42", + "serialized": "BEGIN:VCARD + VERSION:3.0 + UID:folded-photo + FN:Folded Photo + PHOTO;ENCODING=b;TYPE=PNG:AQID + END:VCARD + ", + "serializedBytes": "QkVHSU46VkNBUkQNClZFUlNJT046My4wDQpVSUQ6Zm9sZGVkLXBob3RvDQpGTjpGb2xkZWQgUGhvdG8NClBIT1RPO0VOQ09ESU5HPWI7VFlQRT1QTkc6QVFJRA0KRU5EOlZDQVJEDQo=", + } + `); + }); +}); + +describe('retained vCard property tree', () => { + it('preserves groups, ordered parameters, structured values, escapes, and unknown properties', () => { + const document = parseVCardDocument(NAMETAG_DERIVED_FIXTURE); + + expect(document.version).toBe('3.0'); + expect(document.properties.find(property => property.name === 'TEL')).toEqual({ + group: 'item1', + name: 'TEL', + params: [ + { name: 'TYPE', values: ['CELL', 'VOICE'] }, + { name: 'X-ABLABEL', values: ['Mobile'] }, + ], + rawValue: '+15551234567', + }); + expect(document.properties.find(property => property.name === 'EMAIL').params).toEqual([ + { name: 'TYPE', values: ['INTERNET'] }, + { name: 'TYPE', values: ['WORK'] }, + ]); + expect(document.properties.find(property => property.name === 'N').rawValue) + .toBe('Brown;Emmetiano;Lucas;;'); + expect(document.properties.find(property => property.name === 'NOTE').rawValue) + .toBe('Escaped comma\\, semicolon\\; newline\\nand slash\\\\ retained'); + expect(document.properties.find(property => property.name === 'X-MAILFLOW-UNKNOWN')) + .toEqual({ + group: null, + name: 'X-MAILFLOW-UNKNOWN', + params: [{ name: 'X-LABEL', values: ['one;two'] }], + rawValue: 'opaque\\:value:tail', + }); + expect(document.properties.find(property => property.name === 'X-ABADR')).toEqual({ + group: 'item2', + name: 'X-ABADR', + params: [], + rawValue: 'es', + }); + expect(document.properties.find(property => property.name === 'EXPERTISE')).toEqual({ + group: null, + name: 'EXPERTISE', + params: [{ name: 'X-LEVEL', values: ['9'] }], + rawValue: 'Flux dynamics', + }); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it.each(['3.0', '4.0'])('accepts vCard %s and normalizes case-insensitive names only', version => { + const document = parseVCardDocument([ + 'begin:vcard', + `version:${version}`, + 'Item7.email;type="Work,INTERNET":Mixed.Case@Example.Test', + 'end:vcard', + '', + ].join('\n')); + + expect(document).toEqual({ + version, + properties: [{ + group: 'Item7', + name: 'EMAIL', + params: [{ name: 'TYPE', values: ['Work', 'INTERNET'] }], + rawValue: 'Mixed.Case@Example.Test', + }], + }); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it('splits at the first colon outside quoted parameters', () => { + const document = parseVCardDocument(vcard([ + 'X-REFERENCE;X-URI="urn:example:label";X-ESCAPED=one\\:two:raw\\:value:tail', + ])); + + expect(document.properties[0]).toEqual({ + group: null, + name: 'X-REFERENCE', + params: [ + { name: 'X-URI', values: ['urn:example:label'] }, + { name: 'X-ESCAPED', values: ['one\\'] }, + ], + rawValue: 'two:raw\\:value:tail', + }); + }); + + it('round-trips caret escapes and scans double-quoted parameter delimiters', () => { + const quoted = parseVCardDocument(vcard([ + 'X-REFERENCE;X-LABEL="one;two:three":payload', + ])); + const document = { + version: '4.0', + properties: [{ + group: null, + name: 'X-REFERENCE', + params: [{ + name: 'X-VALUES', + values: ['^n', '^^', '"quoted"', 'line\nbreak'], + }], + rawValue: 'payload', + }], + }; + + expect(quoted.properties[0].params).toEqual([ + { name: 'X-LABEL', values: ['one;two:three'] }, + ]); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it('uses the standards content delimiter after a terminal parameter backslash', () => { + const document = parseVCardDocument(vcard([ + 'X-REFERENCE;X-P=foo\\:https://example.test/a', + ])); + + expect(document.properties[0]).toEqual({ + group: null, + name: 'X-REFERENCE', + params: [{ name: 'X-P', values: ['foo\\'] }], + rawValue: 'https://example.test/a', + }); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it('preserves unquoted parameter whitespace and decoded vCard 4.0 line breaks', () => { + const parsed = parseVCardDocument(vcard([ + 'X-REFERENCE;X-P= padded :payload', + ], '4.0')); + const constructed = { + version: '4.0', + properties: [{ + group: null, + name: 'X-REFERENCE', + params: [{ name: 'X-P', values: ['\n'] }], + rawValue: 'payload', + }], + }; + + expect(parsed.properties[0].params).toEqual([ + { name: 'X-P', values: [' padded '] }, + ]); + expect(parseVCardDocument(serializeVCardDocument(parsed))).toEqual(parsed); + expect(parseVCardDocument(serializeVCardDocument(constructed))).toEqual(constructed); + }); + + it.each(['^n', '^N', '^^', "^'"])( + 'preserves literal vCard 3.0 parameter sequence %s', + value => { + const document = parseVCardDocument(vcard([ + `X-REFERENCE;X-P=literal${value}case:payload`, + ])); + + expect(document.properties[0].params).toEqual([ + { name: 'X-P', values: [`literal${value}case`] }, + ]); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }, + ); + + it.each(['3.0', '4.0'])( + 'preserves literal surrounding apostrophes in vCard %s parameters', + version => { + const document = parseVCardDocument(vcard([ + "X-REFERENCE;X-P='quoted':payload", + ], version)); + + expect(document.properties[0].params).toEqual([ + { name: 'X-P', values: ["'quoted'"] }, + ]); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }, + ); + + it('treats an apostrophe inside an unquoted parameter value as ordinary text', () => { + const document = parseVCardDocument(vcard([ + "X-REFERENCE;X-LABEL=O'Reilly:payload", + ])); + + expect(document.properties[0]).toEqual({ + group: null, + name: 'X-REFERENCE', + params: [{ name: 'X-LABEL', values: ["O'Reilly"] }], + rawValue: 'payload', + }); + }); + + it.each([ + ["ordinary apostrophe", "O'Reilly"], + ['literal surrounding single quotes', "'quoted'"], + ])('round-trips parameter values containing %s', (_case, value) => { + const document = { + version: '4.0', + properties: [{ + group: null, + name: 'X-REFERENCE', + params: [{ name: 'X-LABEL', values: [value] }], + rawValue: 'payload', + }], + }; + + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it('preserves whitespace inside quoted parameter values', () => { + const document = parseVCardDocument(vcard([ + 'X-REFERENCE;X-LABEL= " padded " ;TYPE="CELL,VOICE":payload', + ])); + + expect(document.properties[0]).toEqual({ + group: null, + name: 'X-REFERENCE', + params: [ + { name: 'X-LABEL', values: [' padded '] }, + { name: 'TYPE', values: ['CELL', 'VOICE'] }, + ], + rawValue: 'payload', + }); + const serialized = serializeVCardDocument(document); + expect(serialized).toContain('X-LABEL=" padded ";TYPE=CELL,VOICE:payload\r\n'); + expect(parseVCardDocument(serialized)).toEqual(document); + }); + + it('parses individually quoted and mixed parameter-list entries', () => { + const document = parseVCardDocument(vcard([ + 'X-REFERENCE;P="one","two";Q=plain,"quoted",tail;TYPE="CELL,VOICE":payload', + ])); + + expect(document.properties[0].params).toEqual([ + { name: 'P', values: ['one', 'two'] }, + { name: 'Q', values: ['plain', 'quoted', 'tail'] }, + { name: 'TYPE', values: ['CELL', 'VOICE'] }, + ]); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it('keeps literal commas except in a legacy whole-quoted compound TYPE', () => { + const document = parseVCardDocument(vcard([ + 'X-REFERENCE;P="Last, First";TYPE="CELL,VOICE":payload', + ])); + + expect(document.properties[0].params).toEqual([ + { name: 'P', values: ['Last, First'] }, + { name: 'TYPE', values: ['CELL', 'VOICE'] }, + ]); + }); + + it('quotes comma-containing parameter-list entries individually', () => { + const document = { + version: '4.0', + properties: [{ + group: null, + name: 'X-REFERENCE', + params: [{ + name: 'P', + values: ['Last, First', 'plain', 'Suffix, Jr.'], + }], + rawValue: 'payload', + }], + }; + + const serialized = serializeVCardDocument(document); + + expect(serialized).toContain('P="Last, First",plain,"Suffix, Jr.":payload\r\n'); + expect(parseVCardDocument(serialized)).toEqual(document); + }); + + it('round-trips empty parameter entries and literal backslashes at every boundary', () => { + const document = { + version: '4.0', + properties: [{ + group: null, + name: 'X-REFERENCE', + params: [ + { name: 'P-EMPTY', values: ['', 'first', '', ''] }, + { name: 'P-COMMA', values: ['before-comma\\', 'after'] }, + { name: 'P-SEMICOLON', values: ['before-semicolon\\'] }, + { name: 'P-QUOTE', values: ['before-quote\\"after'] }, + { name: 'P-COLON', values: ['before-colon\\'] }, + ], + rawValue: 'payload', + }], + }; + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it.each([1, 2, 4])('preserves %i external parameter backslashes exactly', count => { + const slashes = '\\'.repeat(count); + const document = parseVCardDocument(vcard([ + 'X-REFERENCE' + + `;P-UNQUOTED=${slashes}ordinary` + + `;P-QUOTED="${slashes}ordinary,${slashes}semi;${slashes}colon:tail"` + + `;P-COMMA=${slashes},tail` + + `;P-SEMICOLON=${slashes};P-NEXT=next` + + `;P-COLON=${slashes}:payload`, + ])); + + expect(document.properties[0]).toEqual({ + group: null, + name: 'X-REFERENCE', + params: [ + { name: 'P-UNQUOTED', values: [`${slashes}ordinary`] }, + { + name: 'P-QUOTED', + values: [`${slashes}ordinary,${slashes}semi;${slashes}colon:tail`], + }, + { name: 'P-COMMA', values: [slashes, 'tail'] }, + { name: 'P-SEMICOLON', values: [slashes] }, + { name: 'P-NEXT', values: ['next'] }, + { name: 'P-COLON', values: [slashes] }, + ], + rawValue: 'payload', + }); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it('folds UTF-8 content at 75 octets without splitting code points', () => { + const document = { + version: '4.0', + properties: [{ + group: null, + name: 'NOTE', + params: [], + rawValue: `Résumé ${'🙂'.repeat(40)} fin`, + }], + }; + + const serialized = serializeVCardDocument(document); + const physicalLines = serialized.split('\r\n').filter(Boolean); + + expect(physicalLines.some(line => line.startsWith(' '))).toBe(true); + expect(physicalLines.every(line => Buffer.byteLength(line, 'utf8') <= 75)).toBe(true); + expect(serialized).not.toContain('\ufffd'); + expect(parseVCardDocument(serialized)).toEqual(document); + }); +}); + +describe('vCard safety boundaries', () => { + it('accepts exactly 1 MiB and rejects one decoded vCard byte more', () => { + const atLimit = asciiVCardOfSize(MAX_VCARD_BYTES); + const overLimit = asciiVCardOfSize(MAX_VCARD_BYTES + 1); + + expect(Buffer.byteLength(atLimit)).toBe(MAX_VCARD_BYTES); + expect(parseVCardDocument(atLimit).properties).toHaveLength(16); + expect(() => parseVCardDocument(overLimit)).toThrow(/1 MiB/); + }); + + it('accepts 2,000 properties and rejects property 2,001', () => { + const properties = Array.from({ length: MAX_PROPERTIES }, (_, index) => `X-P:${index}`); + + expect(parseVCardDocument(vcard(properties)).properties).toHaveLength(MAX_PROPERTIES); + expect(() => parseVCardDocument(vcard([...properties, 'X-P:overflow']))) + .toThrow(/2,000 properties/); + }); + + it('accepts 64 ordered parameters and rejects parameter 65', () => { + const parameters = Array.from({ length: MAX_PARAMETERS }, (_, index) => `;P${index}=v`) + .join(''); + + expect(parseVCardDocument(vcard([`TEL${parameters}:1`])).properties[0].params) + .toHaveLength(MAX_PARAMETERS); + expect(() => parseVCardDocument(vcard([`TEL${parameters};OVERFLOW=v:1`]))) + .toThrow(/64 parameters/); + }); + + it('accepts a 64 KiB physical line and rejects one physical byte more', () => { + const atLimit = `NOTE:${'a'.repeat(MAX_CONTENT_LINE_BYTES - 5)}`; + const overLimit = `${atLimit}a`; + + expect(Buffer.byteLength(atLimit)).toBe(MAX_CONTENT_LINE_BYTES); + expect(parseVCardDocument(vcard([atLimit])).properties[0].rawValue) + .toHaveLength(MAX_CONTENT_LINE_BYTES - 5); + expect(() => parseVCardDocument(vcard([overLimit]))).toThrow(/64 KiB physical line/); + }); + + it('accepts a 64 KiB unfolded non-PHOTO line and rejects one unfolded byte more', () => { + const firstValue = 'a'.repeat(40_000); + const exactTail = 'b'.repeat(MAX_CONTENT_LINE_BYTES - 5 - firstValue.length); + const exact = vcard([`NOTE:${firstValue}\r\n ${exactTail}`]); + const over = vcard([`NOTE:${firstValue}\r\n ${exactTail}b`]); + + expect(parseVCardDocument(exact).properties[0].rawValue) + .toHaveLength(MAX_CONTENT_LINE_BYTES - 5); + expect(() => parseVCardDocument(over)).toThrow(/64 KiB unfolded line/); + }); + + it('applies the unfolded-line limit to VERSION at its exact boundary', () => { + const suffix = '3.0'; + const prefix = 'VERSION:'; + const exactLine = prefix + + ' '.repeat(MAX_CONTENT_LINE_BYTES - Buffer.byteLength(prefix + suffix)) + + suffix; + const overLine = `${exactLine} `; + + expect(Buffer.byteLength(exactLine)).toBe(MAX_CONTENT_LINE_BYTES); + const card = versionLine => [ + 'BEGIN:VCARD', + foldAsciiLine(versionLine), + 'END:VCARD', + '', + ].join('\r\n'); + + expect(parseVCardDocument(card(exactLine)).version).toBe('3.0'); + expect(() => parseVCardDocument(card(overLine))) + .toThrow(/64 KiB unfolded line/); + }); + + it('allows folded PHOTO past the unfolded-line cap through 512 KiB decoded', () => { + const photo = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64'); + const folded = foldAsciiLine(`PHOTO;ENCODING=b;TYPE=JPEG:${photo}`); + const document = parseVCardDocument(vcard([folded])); + + expect(document.properties[0].rawValue).toBe(photo); + }); + + it('limits folded URI PHOTO by unfolded bytes without shrinking embedded PHOTO capacity', () => { + const header = 'PHOTO;VALUE=URI:'; + const exactUri = foldAsciiLine(header + 'a'.repeat(MAX_CONTENT_LINE_BYTES - header.length)); + const oversizedUri = foldAsciiLine( + header + 'a'.repeat(MAX_CONTENT_LINE_BYTES - header.length + 1), + ); + const embedded = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64'); + + expect(parseVCardDocument(vcard([exactUri])).properties[0].rawValue) + .toHaveLength(MAX_CONTENT_LINE_BYTES - header.length); + expect(() => parseVCardDocument(vcard([oversizedUri]))) + .toThrow('vCard exceeds the 64 KiB unfolded line limit'); + expect(() => serializeVCardDocument({ + version: '4.0', + properties: [{ + group: null, + name: 'PHOTO', + params: [{ name: 'VALUE', values: ['URI'] }], + rawValue: 'a'.repeat(MAX_CONTENT_LINE_BYTES - header.length + 1), + }], + })).toThrow('vCard exceeds the 64 KiB unfolded line limit'); + expect(() => parseVCardDocument(vcard([ + foldAsciiLine(`PHOTO;ENCODING=b;TYPE=JPEG:${embedded}`), + ]))).not.toThrow(); + }); + + it('rejects a decoded PHOTO one byte beyond 512 KiB', () => { + const photo = Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64'); + const folded = foldAsciiLine(`PHOTO;ENCODING=b;TYPE=JPEG:${photo}`); + + expect(() => parseVCardDocument(vcard([folded]))).toThrow(/512 KiB photo/); + }); + + it.each([ + ['parsing', photo => parseVCardDocument(vcard([foldAsciiLine(`PHOTO:${photo}`)]))], + ['serialization', photo => serializeVCardDocument({ + version: '3.0', + properties: [{ group: null, name: 'PHOTO', params: [], rawValue: photo }], + })], + ])('accepts exactly 512 KiB and rejects one byte more for legacy PHOTO %s', (_case, run) => { + const atLimit = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64'); + const overLimit = Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64'); + + expect(() => run(atLimit)).not.toThrow(); + expect(() => run(overLimit)).toThrow(/512 KiB photo/); + }); + + it('rejects oversized base64 PHOTO data before decoding it', () => { + const photo = Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64'); + const bufferFrom = vi.spyOn(Buffer, 'from'); + + try { + expect(() => serializeVCardDocument({ + version: '3.0', + properties: [{ + group: null, + name: 'PHOTO', + params: [{ name: 'ENCODING', values: ['b'] }], + rawValue: photo, + }], + })).toThrow(/512 KiB photo/); + expect(bufferFrom.mock.calls.some(([value]) => ( + typeof value === 'string' && value.length > MAX_PHOTO_BYTES + ))).toBe(false); + } finally { + bufferFrom.mockRestore(); + } + }); + + it('checks the document byte budget before decoding a valid PHOTO', () => { + const photo = Buffer.alloc(MAX_PHOTO_BYTES, 0xa5).toString('base64'); + const encodedPhoto = photo.replace(/=+$/, ''); + const bufferFrom = vi.spyOn(Buffer, 'from'); + + try { + expect(() => serializeVCardDocument({ + version: '3.0', + properties: [ + ...Array.from({ length: 6 }, (_value, index) => ({ + group: null, + name: `X-FILL-${index}`, + params: [], + rawValue: 'a'.repeat(60 * 1024), + })), + { + group: null, + name: 'PHOTO', + params: [{ name: 'ENCODING', values: ['b'] }], + rawValue: photo, + }, + ], + })).toThrow(/1 MiB/); + expect(bufferFrom.mock.calls.some(([value]) => value === encodedPhoto)).toBe(false); + } finally { + bufferFrom.mockRestore(); + } + }); + + it('rejects an oversized URI PHOTO before folding its content line', () => { + const rawValue = 'https://example.test/' + 'a'.repeat(MAX_VCARD_BYTES); + const bufferFrom = vi.spyOn(Buffer, 'from'); + + try { + expect(() => serializeVCardDocument({ + version: '4.0', + properties: [{ + group: null, + name: 'PHOTO', + params: [{ name: 'VALUE', values: ['URI'] }], + rawValue, + }], + })).toThrow(/1 MiB/); + expect(bufferFrom.mock.calls.some(([value]) => ( + typeof value === 'string' && value.length > MAX_VCARD_BYTES + ))).toBe(false); + } finally { + bufferFrom.mockRestore(); + } + }); + + it.each([ + ['parsing', photo => parseVCardDocument(vcard([`PHOTO:${photo}`]))], + ['serialization', photo => serializeVCardDocument({ + version: '3.0', + properties: [{ group: null, name: 'PHOTO', params: [], rawValue: photo }], + })], + ])('rejects malformed legacy PHOTO base64 during %s', (_case, run) => { + expect(() => run('not-valid-***')).toThrow(/invalid base64/); + }); + + it.each([ + ['invalid alphabet', 'AQI*'], + ['impossible unpadded sextet length', 'A'], + ['incomplete padded quartet', 'A=='], + ['padding without payload', '=='], + ['padding before the end', 'AA=A'], + ['noncanonical discarded bits after one byte', 'AB=='], + ['noncanonical discarded bits after two bytes', 'AAB='], + ].flatMap(([reason, photo]) => [ + [reason, 'parsing', () => parseVCardDocument(vcard([ + `PHOTO;ENCODING=b;TYPE=JPEG:${photo}`, + ]))], + [reason, 'serialization', () => serializeVCardDocument({ + version: '3.0', + properties: [{ + group: null, + name: 'PHOTO', + params: [{ name: 'ENCODING', values: ['b'] }], + rawValue: photo, + }], + })], + ]))('rejects base64 with %s during %s', (_reason, _operation, run) => { + expect(run).toThrow(/invalid base64/); + }); + + it.each([ + ['padded', 'AQI='], + ['unpadded', 'AQI'], + ['ASCII whitespace', 'AQ I='], + ])('accepts canonical %s base64 PHOTO data', (_case, photo) => { + expect(() => parseVCardDocument(vcard([ + `PHOTO;ENCODING=b;TYPE=JPEG:${photo}`, + ]))).not.toThrow(); + expect(() => serializeVCardDocument({ + version: '3.0', + properties: [{ + group: null, + name: 'PHOTO', + params: [{ name: 'ENCODING', values: ['b'] }], + rawValue: photo, + }], + })).not.toThrow(); + }); + + it('accepts arbitrary percent-encoded octets in non-base64 data URI photos', () => { + const rawValue = 'data:image/png,%89PNG%0D%0A'; + const document = parseVCardDocument(vcard([`PHOTO:${rawValue}`], '4.0')); + + expect(document.properties[0].rawValue).toBe(rawValue); + expect(contactFromVCardDocument(document).photoData).toBe(rawValue); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it.each([ + ['parsing', rawValue => parseVCardDocument(vcard([`PHOTO:${rawValue}`], '4.0'))], + ['serialization', rawValue => serializeVCardDocument({ + version: '4.0', + properties: [{ group: null, name: 'PHOTO', params: [], rawValue }], + })], + ])('rejects malformed percent escapes in non-base64 data URI photos during %s', ( + _case, + run, + ) => { + expect(() => run('data:image/png,%8G')).toThrow(/invalid data URI encoding/); + expect(() => run('data:image/png,%')).toThrow(/invalid data URI encoding/); + }); + + it.each([ + ['parsing', rawValue => parseVCardDocument(vcard([ + foldAsciiLine(`PHOTO:${rawValue}`), + ], '4.0'))], + ['serialization', rawValue => serializeVCardDocument({ + version: '4.0', + properties: [{ group: null, name: 'PHOTO', params: [], rawValue }], + })], + ])('counts decoded bytes for non-base64 data URI photos during %s', (_case, run) => { + const prefix = 'data:application/octet-stream,'; + const atLimit = prefix + 'a'.repeat(MAX_PHOTO_BYTES); + const overLimit = `${atLimit}a`; + + expect(() => run(atLimit)).not.toThrow(); + expect(() => run(overLimit)).toThrow(/512 KiB photo/); + }); + + it.each([ + ['group line break', { + group: 'item1\r\nX-INJECT', name: 'X-SAFE', params: [], rawValue: 'value', + }], + ['group delimiter', { + group: 'item1;TYPE=WORK', name: 'X-SAFE', params: [], rawValue: 'value', + }], + ['property-name line break', { + group: null, name: 'X-SAFE\r\nX-INJECT', params: [], rawValue: 'value', + }], + ['property-name delimiter', { + group: null, name: 'X-SAFE;TYPE=WORK', params: [], rawValue: 'value', + }], + ['parameter-name line break', { + group: null, + name: 'X-SAFE', + params: [{ name: 'TYPE\r\nX-INJECT', values: ['WORK'] }], + rawValue: 'value', + }], + ['parameter-name delimiter', { + group: null, + name: 'X-SAFE', + params: [{ name: 'TYPE:INJECT', values: ['WORK'] }], + rawValue: 'value', + }], + ['raw-value carriage return', { + group: null, name: 'X-SAFE', params: [], rawValue: 'value\rX-INJECT:payload', + }], + ['raw-value newline', { + group: null, name: 'X-SAFE', params: [], rawValue: 'value\nX-INJECT:payload', + }], + ])('rejects serializer structural injection through %s', (_case, property) => { + expect(() => serializeVCardDocument({ + version: '4.0', + properties: [property], + })).toThrow(/invalid (?:property group|property name|parameter name)|line break/); + }); +}); + +describe('MailFlow contact projection', () => { + const richDocument = () => parseVCardDocument(vcard([ + 'UID:contact-1', + 'FN:Jane Doe', + 'N:Doe;Jane;;;', + 'EMAIL;TYPE=INTERNET,WORK:Jane.Doe@Example.Test', + 'TEL;TYPE=CELL,VOICE:+1 555 123 4567', + 'ORG:Example Corp;Research', + 'NOTE:Line one\\nLine two', + 'item1.ADR;TYPE=WORK:PO Box 1;Floor 2;123 Main St\\nUnit 4;Vancouver;BC;V1V 1V1;Canada', + 'item1.X-ABLabel:Office', + 'URL;TYPE=HOME:https://example.test/jane', + 'item2.IMPP:x-apple:jane_handle', + 'item2.X-ABLabel:Signal', + 'BDAY:1990-02-03', + 'ANNIVERSARY:2020-04-05', + 'item3.X-ABDATE:2012-06-07', + 'item3.X-ABLabel:Graduation', + 'ROLE:Engineering Lead', + 'TITLE:Principal Engineer', + 'NICKNAME:JD', + 'GEO:geo:49.2827,-123.1207', + 'item4.X-MAILFLOW-CUSTOM:Blue', + 'item4.X-ABLabel:Favorite color', + ])); + + it('projects core fields and every supported Additional-field kind', () => { + const first = contactFromVCardDocument(richDocument()); + const second = contactFromVCardDocument(richDocument()); + + expect(first).toMatchObject({ + uid: 'contact-1', + displayName: 'Jane Doe', + firstName: 'Jane', + lastName: 'Doe', + emails: [{ value: 'jane.doe@example.test', type: 'work', primary: false }], + phones: [{ value: '+1 555 123 4567', type: 'mobile' }], + organization: 'Example Corp', + notes: 'Line one\nLine two', + photoData: null, + }); + expect(first.additionalFields.map(({ kind, label, value }) => ({ kind, label, value }))) + .toEqual([ + { + kind: 'postal-address', + label: 'Office', + value: { + poBox: 'PO Box 1', + extendedAddress: 'Floor 2', + street: '123 Main St\nUnit 4', + locality: 'Vancouver', + region: 'BC', + postalCode: 'V1V 1V1', + country: 'Canada', + }, + }, + { kind: 'url', label: 'HOME', value: 'https://example.test/jane' }, + { + kind: 'im', + label: 'Signal', + value: { protocol: 'signal', handle: 'jane_handle' }, + }, + { kind: 'birthday', label: 'Birthday', value: '1990-02-03' }, + { kind: 'anniversary', label: 'Anniversary', value: '2020-04-05' }, + { kind: 'date', label: 'Graduation', value: '2012-06-07' }, + { kind: 'role', label: 'Role', value: 'Engineering Lead' }, + { kind: 'title', label: 'Title', value: 'Principal Engineer' }, + { kind: 'nickname', label: 'Nickname', value: 'JD' }, + { + kind: 'geo', + label: 'Location', + value: { latitude: 49.2827, longitude: -123.1207 }, + }, + { kind: 'custom-text', label: 'Favorite color', value: 'Blue' }, + ]); + expect(new Set(first.additionalFields.map(field => field.id)).size) + .toBe(first.additionalFields.length); + expect(first.additionalFields.map(field => field.id)) + .toEqual(second.additionalFields.map(field => field.id)); + expect(first.additionalFields.every(field => ( + typeof field.vcard.name === 'string' + && Array.isArray(field.vcard.params) + && Object.hasOwn(field.vcard, 'group') + ))).toBe(true); + }); + + it('serializes and round-trips a label-less typed Additional field', () => { + const contact = { + uid: 'labelless-birthday', + displayName: 'No Label', + firstName: null, + lastName: null, + emails: [], + phones: [], + organization: null, + notes: null, + photoData: null, + additionalFields: [ + { id: 'field-1', kind: 'birthday', label: '', value: '1985-07-12' }, + ], + }; + + // A canonical/typed field maps to a real vCard property (BDAY) and only needs a + // label for X-ABLABEL grouping. A blank label must not throw on serialization. + expect(() => localContactHash(contact)).not.toThrow(); + const overlaid = overlayContactOnVCard({ version: '3.0', properties: [] }, contact); + const serialized = serializeVCardDocument(overlaid); + expect(overlaid.properties.some(property => property.name === 'BDAY')).toBe(true); + expect(serialized).not.toMatch(/X-ABLABEL/i); + + const reparsed = contactFromVCardDocument(parseVCardDocument(serialized)); + expect(reparsed.additionalFields).toEqual([ + expect.objectContaining({ kind: 'birthday', value: '1985-07-12' }), + ]); + }); + + it('still requires a label for a custom-text Additional field', () => { + const contact = { + uid: 'labelless-custom', + displayName: 'No Label', + emails: [], + phones: [], + additionalFields: [ + { id: 'field-1', kind: 'custom-text', label: '', value: 'Blue' }, + ], + }; + expect(() => overlayContactOnVCard({ version: '3.0', properties: [] }, contact)) + .toThrow(/custom text field requires a label/); + }); + + it('keeps URL photos opaque and never fetches them', () => { + const originalFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = () => { + fetchCalled = true; + throw new Error('unexpected fetch'); + }; + + try { + const document = parseVCardDocument(vcard([ + 'UID:url-photo', + 'FN:URL Photo', + 'PHOTO;VALUE=URI:https://images.example.test/photo.jpg', + ])); + const contact = contactFromVCardDocument(document); + + expect(contact.photoData).toBeNull(); + expect(document.properties.find(property => property.name === 'PHOTO').rawValue) + .toBe('https://images.example.test/photo.jpg'); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('retains explicit non-HTTP URI photos through projection and explicit-null overlay', () => { + const originalFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = () => { + fetchCalled = true; + throw new Error('unexpected fetch'); + }; + + try { + const document = parseVCardDocument(vcard([ + 'UID:cid-photo', + 'FN:CID Photo', + 'PHOTO;VALUE=URI:cid:photo1', + ])); + const photo = document.properties.find(property => property.name === 'PHOTO'); + const contact = contactFromVCardDocument(document); + const overlaid = overlayContactOnVCard(document, { ...contact, photoData: null }); + + expect(photo).toEqual({ + group: null, + name: 'PHOTO', + params: [{ name: 'VALUE', values: ['URI'] }], + rawValue: 'cid:photo1', + }); + expect(contact.photoData).toBeNull(); + expect(overlaid.properties.find(property => property.name === 'PHOTO')).toEqual(photo); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('treats an unparameterized vCard 4.0 PHOTO as an opaque URI', () => { + const originalFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = () => { + fetchCalled = true; + throw new Error('unexpected fetch'); + }; + + try { + const document = parseVCardDocument([ + 'BEGIN:VCARD', + 'VERSION:4.0', + 'PHOTO:cid:photo1', + 'UID:cid-photo-default', + 'FN:CID Photo Default', + 'END:VCARD', + '', + ].join('\r\n')); + const photo = document.properties.find(property => property.name === 'PHOTO'); + const contact = contactFromVCardDocument(document); + const overlaid = overlayContactOnVCard(document, { ...contact, photoData: null }); + const reparsed = parseVCardDocument(serializeVCardDocument(document)); + + expect(photo).toEqual({ + group: null, + name: 'PHOTO', + params: [], + rawValue: 'cid:photo1', + }); + expect(contact.photoData).toBeNull(); + expect(overlaid.properties.find(property => property.name === 'PHOTO')).toEqual(photo); + expect(reparsed).toEqual(document); + expect(semanticVCardHash(reparsed)).toBe(semanticVCardHash(document)); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('distinguishes an absent photo from invalid embedded photo data', () => { + const withoutPhoto = parseVCardDocument(vcard(['UID:no-photo', 'FN:No Photo'])); + + expect(contactFromVCardDocument(withoutPhoto).photoData).toBeNull(); + expect(() => parseVCardDocument(vcard([ + 'UID:bad-photo', + 'FN:Bad Photo', + 'PHOTO;ENCODING=b;TYPE=JPEG:not-valid-***', + ]))).toThrow(/invalid base64/); + }); + + it.each([ + ['vCard 3.0 JPEG', 'PHOTO;ENCODING=b;TYPE=JPEG:AQID', '3.0', + 'data:image/jpeg;base64,AQID'], + ['vCard 3.0 PNG', 'PHOTO;ENCODING=b;TYPE=PNG:AQID', '3.0', + 'data:image/png;base64,AQID'], + ['vCard 3.0 GIF', 'PHOTO;ENCODING=b;TYPE=GIF:AQID', '3.0', + 'data:image/gif;base64,AQID'], + ['vCard 3.0 WebP', 'PHOTO;ENCODING=b;TYPE=WEBP:AQID', '3.0', + 'data:image/webp;base64,AQID'], + ['vCard 4.0 JPEG data URI', 'PHOTO:data:image/jpeg;base64,AQID', '4.0', + 'data:image/jpeg;base64,AQID'], + ['vCard 4.0 PNG data URI', 'PHOTO:data:image/png;base64,AQID', '4.0', + 'data:image/png;base64,AQID'], + ['vCard 4.0 GIF data URI', 'PHOTO:data:image/gif;base64,AQID', '4.0', + 'data:image/gif;base64,AQID'], + ['vCard 4.0 WebP data URI', 'PHOTO:data:image/webp;base64,AQID', '4.0', + 'data:image/webp;base64,AQID'], + ])('projects supported %s photos', (_case, photo, version, expected) => { + const document = parseVCardDocument(vcard([photo], version)); + + expect(contactFromVCardDocument(document).photoData).toBe(expected); + }); + + it.each([ + ['HTML data URI', 'PHOTO:data:text/html;base64,PGgxPk5vdCBhIHBob3RvPC9oMT4=', '4.0'], + ['BMP binary', 'PHOTO;ENCODING=b;TYPE=BMP:AQID', '3.0'], + ['unknown binary type', 'PHOTO;ENCODING=b;TYPE=PDF:AQID', '3.0'], + ])('keeps unsupported %s PHOTO media opaque and unrendered', (_case, photo, version) => { + const document = parseVCardDocument(vcard([photo], version)); + const contact = contactFromVCardDocument(document); + + expect(contact.photoData).toBeNull(); + expect(overlayContactOnVCard(document, contact)).toEqual(document); + }); +}); + +describe('MailFlow contact overlay', () => { + const losslessDocument = () => parseVCardDocument(vcard([ + 'UID:lossless-contact', + 'FN:Jane Doe', + 'N:Doe;Jane;Middle;Dr.;Jr.', + 'item1.EMAIL;TYPE=WORK;PREF=1;X-VENDOR=keep:jane@example.test', + 'item1.X-ABLabel:Work inbox', + 'ORG:Example Corp;Research Division', + 'item2.URL;TYPE=HOME;X-VENDOR=keep:https://example.test/jane', + 'item2.X-ABLabel:Portfolio', + 'GEO:not-a-coordinate', + ])); + + it('preserves every retained property on an unchanged overlay', () => { + const document = losslessDocument(); + const contact = contactFromVCardDocument(document); + + expect(overlayContactOnVCard(document, contact)).toEqual(document); + }); + + it('changes only the matched owned email value', () => { + const document = losslessDocument(); + const contact = contactFromVCardDocument(document); + contact.emails[0].value = 'updated@example.test'; + const expected = structuredClone(document); + expected.properties.find(property => property.name === 'EMAIL').rawValue + = 'updated@example.test'; + + expect(overlayContactOnVCard(document, contact)).toEqual(expected); + }); + + it.each([ + ['3.0', 'TYPE=WORK;X-VENDOR=first', 'TYPE=HOME,PREF;X-VENDOR=second'], + ['4.0', 'TYPE=WORK;PREF=2;X-VENDOR=first', 'TYPE=HOME;PREF=1;X-VENDOR=second'], + ])('round-trips email primary edits using vCard %s preference semantics', ( + version, + firstParams, + secondParams, + ) => { + const document = parseVCardDocument(vcard([ + `item1.EMAIL;${firstParams}:first@example.test`, + 'item1.X-ABLabel:First inbox', + `item2.EMAIL;${secondParams}:second@example.test`, + 'item2.X-ABLabel:Second inbox', + ], version)); + const fallback = contactFromVCardDocument(parseVCardDocument(vcard([ + 'EMAIL:first@example.test', + 'EMAIL:second@example.test', + ], version))); + const contact = contactFromVCardDocument(document); + const edited = structuredClone(contact); + edited.emails[0].primary = true; + edited.emails[1].primary = false; + + const overlaid = overlayContactOnVCard(document, edited); + const serialized = serializeVCardDocument(overlaid); + const projected = contactFromVCardDocument(parseVCardDocument(serialized)); + const emailProperties = overlaid.properties.filter(property => property.name === 'EMAIL'); + + expect(fallback.emails.map(email => email.primary)).toEqual([false, false]); + expect(contact.emails.map(email => email.primary)).toEqual([false, true]); + expect(localContactHash(edited)).not.toBe(localContactHash(contact)); + expect(serialized).not.toBe(serializeVCardDocument(document)); + expect(projected.emails.map(email => email.primary)).toEqual([true, false]); + expect(emailProperties.map(property => property.group)).toEqual(['item1', 'item2']); + expect(emailProperties.map(property => parameterValuesForTest(property, 'X-VENDOR'))) + .toEqual([['first'], ['second']]); + expect(overlaid.properties.filter(property => property.name === 'X-ABLABEL')) + .toEqual(document.properties.filter(property => property.name === 'X-ABLABEL')); + if (version === '4.0') { + expect(emailProperties.map(property => parameterValuesForTest(property, 'PREF'))) + .toEqual([['1'], []]); + } else { + expect(emailProperties.map(property => parameterValuesForTest(property, 'TYPE'))) + .toEqual([['WORK', 'PREF'], ['HOME']]); + } + }); + + it.each([ + ['3.0', 'TYPE=WORK,PREF', 'TYPE=HOME'], + ['4.0', 'TYPE=WORK;PREF=1', 'TYPE=HOME'], + ])('round-trips an all-false email primary edit using vCard %s', ( + version, + firstParams, + secondParams, + ) => { + const document = parseVCardDocument(vcard([ + 'UID:all-false-primary', + 'FN:All False Primary', + `EMAIL;${firstParams}:first@example.test`, + `EMAIL;${secondParams}:second@example.test`, + ], version)); + const contact = contactFromVCardDocument(document); + const edited = structuredClone(contact); + edited.emails.forEach(email => { email.primary = false; }); + + const overlaid = overlayContactOnVCard(document, edited); + const serialized = serializeVCardDocument(overlaid); + const reparsed = parseVCardDocument(serialized); + const projected = contactFromVCardDocument(reparsed); + const emailProperties = overlaid.properties.filter(property => property.name === 'EMAIL'); + + expect(contact.emails.map(email => email.primary)).toEqual([true, false]); + expect(localContactHash(edited)).not.toBe(localContactHash(contact)); + expect(projected.emails.map(email => email.primary)).toEqual([false, false]); + expect(localContactHash(projected)).toBe(localContactHash(edited)); + expect(overlayContactOnVCard(reparsed, projected)).toEqual(reparsed); + expect(emailProperties.flatMap(property => parameterValuesForTest(property, 'PREF'))) + .toEqual([]); + expect(emailProperties.flatMap(property => parameterValuesForTest(property, 'TYPE'))) + .not.toContain('PREF'); + }); + + it('clears every ranked vCard 4 preference without rewriting an unchanged card', () => { + const document = parseVCardDocument(vcard([ + 'EMAIL;PREF=1:first@example.test', + 'EMAIL;PREF=2:second@example.test', + ], '4.0')); + const contact = contactFromVCardDocument(document); + const originalBytes = serializeVCardDocument(document); + const edited = structuredClone(contact); + edited.emails.forEach(email => { email.primary = false; }); + + const overlaid = overlayContactOnVCard(document, edited); + const reparsedDocument = parseVCardDocument(serializeVCardDocument(overlaid)); + const reparsed = contactFromVCardDocument(reparsedDocument); + + expect(serializeVCardDocument(overlayContactOnVCard(document, contact))) + .toBe(originalBytes); + expect(overlaid.properties.filter(property => property.name === 'EMAIL') + .flatMap(property => parameterValuesForTest(property, 'PREF'))).toEqual([]); + expect(reparsed.emails.map(email => email.primary)).toEqual([false, false]); + expect(localContactHash(reparsed)).toBe(localContactHash(edited)); + }); + + it('does not append missing UID or FN properties when projected values are empty', () => { + const document = parseVCardDocument(vcard([ + 'X-REMOTE:opaque', + ])); + + expect(overlayContactOnVCard(document, contactFromVCardDocument(document))) + .toEqual(document); + }); + + it.each([ + ['missing', ['N:Doe;Jane;;;']], + ['blank', ['FN:', 'N:Doe;Jane;;;']], + ])('preserves an unchanged %s retained FN', (_case, lines) => { + const document = parseVCardDocument(vcard([ + 'UID:retained-fn', + ...lines, + 'EMAIL:jane@example.test', + ])); + const contact = contactFromVCardDocument(document); + + const overlaid = overlayContactOnVCard(document, contact); + + expect(contact.displayName).toBeNull(); + expect(overlaid).toEqual(document); + expect(localContactHash(contactFromVCardDocument(overlaid))) + .toBe(localContactHash(contact)); + }); + + it('updates owned fields, drops protected metadata, and preserves an unknown grouped property', () => { + const document = parseVCardDocument(vcard([ + 'PRODID:-//Remote server//EN', + 'REV:20260101T000000Z', + 'UID:old-uid', + 'FN:Old Name', + 'N:Name;Old;;;', + 'EMAIL;TYPE=HOME:old@example.test', + 'NOTE:Old note', + 'item8.X-REMOTE-OPAQUE;X-ORDER="one;two":raw\\,opaque', + 'PHOTO;VALUE=URI:https://images.example.test/remote.jpg', + ], '4.0')); + const originalUnknown = structuredClone( + document.properties.find(property => property.name === 'X-REMOTE-OPAQUE'), + ); + const contact = contactFromVCardDocument(document); + const overlaid = overlayContactOnVCard(document, { + ...contact, + uid: 'new-uid', + displayName: 'New Name', + firstName: 'New', + lastName: 'Name', + emails: [{ value: 'new@example.test', type: 'work', primary: true }], + notes: 'New note', + }); + const projected = contactFromVCardDocument(overlaid); + + expect(overlaid.version).toBe('4.0'); + expect(overlaid.properties.some(property => property.name === 'PRODID')).toBe(false); + expect(overlaid.properties.some(property => property.name === 'REV')).toBe(false); + expect(overlaid.properties.find(property => property.name === 'X-REMOTE-OPAQUE')) + .toEqual(originalUnknown); + expect(overlaid.properties.find(property => property.name === 'PHOTO').rawValue) + .toBe('https://images.example.test/remote.jpg'); + expect(projected).toMatchObject({ + uid: 'new-uid', + displayName: 'New Name', + firstName: 'New', + lastName: 'Name', + emails: [{ value: 'new@example.test', type: 'work', primary: true }], + notes: 'New note', + photoData: null, + }); + expect(document.properties.some(property => property.name === 'PRODID')).toBe(true); + }); + + it('treats null embedded photo data as explicit removal', () => { + const document = parseVCardDocument(vcard([ + 'UID:photo-remove', + 'FN:Photo Remove', + 'PHOTO;ENCODING=b;TYPE=PNG:AQID', + ])); + const contact = contactFromVCardDocument(document); + + expect(contact.photoData).toBe('data:image/png;base64,AQID'); + expect(overlayContactOnVCard(document, { ...contact, photoData: null }).properties) + .not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'PHOTO' })])); + }); + + it('removes legacy unparameterized base64 photos on explicit null', () => { + const document = parseVCardDocument(vcard([ + 'UID:legacy-photo-remove', + 'FN:Legacy Photo Remove', + 'PHOTO:AQID', + ])); + const contact = contactFromVCardDocument(document); + + expect(contact.photoData).toBe('data:image/jpeg;base64,AQID'); + expect(overlayContactOnVCard(document, { ...contact, photoData: null }).properties) + .not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'PHOTO' })])); + }); + + it.each([ + ['JPEG', 'data:image/jpeg;charset=binary,%FF%D8%FF', '/9j/'], + ['PNG', 'data:image/png;charset=binary,%89PNG%0D%0A', 'iVBORw0K'], + ])('canonicalizes owned vCard 3.0 percent-encoded %s photos', ( + type, + photoData, + payload, + ) => { + const document = parseVCardDocument(vcard([ + 'UID:data-uri-photo', + 'FN:Data URI Photo', + ])); + const contact = contactFromVCardDocument(document); + + const overlaid = overlayContactOnVCard(document, { ...contact, photoData }); + const serialized = serializeVCardDocument(overlaid); + const projected = contactFromVCardDocument(parseVCardDocument(serialized)); + const photo = overlaid.properties.find(property => property.name === 'PHOTO'); + + expect(photo).toEqual({ + group: null, + name: 'PHOTO', + params: [ + { name: 'ENCODING', values: ['b'] }, + { name: 'TYPE', values: [type] }, + ], + rawValue: payload, + }); + expect(serialized).toContain(`PHOTO;ENCODING=b;TYPE=${type}:${payload}\r\n`); + expect(localContactHash(projected)).toBe(localContactHash({ ...contact, photoData })); + }); + + it('rewrites an edited PHOTO in place without dropping retained metadata', () => { + const document = parseVCardDocument(vcard([ + 'item7.PHOTO;ENCODING=b;TYPE=PNG;X-VENDOR=keep:AQID', + 'item7.X-ABLabel:Avatar', + ])); + const contact = contactFromVCardDocument(document); + const photoData = 'data:image/png;base64,BAUG'; + + const overlaid = overlayContactOnVCard(document, { ...contact, photoData }); + const photo = overlaid.properties.find(property => property.name === 'PHOTO'); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + + expect(photo.group).toBe('item7'); + expect(parameterValuesForTest(photo, 'X-VENDOR')).toEqual(['keep']); + expect(overlaid.properties).toContainEqual({ + group: 'item7', + name: 'X-ABLABEL', + params: [], + rawValue: 'Avatar', + }); + expect(reparsed.photoData).toBe(photoData); + expect(localContactHash(reparsed)).toBe(localContactHash({ ...contact, photoData })); + }); + + it.each([ + ['HTML', 'data:text/html;base64,PGgxPk5vdCBhIHBob3RvPC9oMT4='], + ['BMP', 'data:image/bmp;base64,AQID'], + ['SVG', 'data:image/svg+xml,'], + ])('rejects unsupported outbound owned %s photos', (_case, photoData) => { + const document = parseVCardDocument(vcard([ + 'UID:unsupported-outbound-photo', + 'FN:Unsupported Outbound Photo', + ])); + const contact = contactFromVCardDocument(document); + + expect(() => overlayContactOnVCard(document, { ...contact, photoData })) + .toThrow(/unsupported PHOTO MIME type/); + }); + + it('serializes new custom text as a grouped property without item-group collisions', () => { + const document = parseVCardDocument(vcard([ + 'item1.X-REMOTE:first', + 'item3.X-REMOTE:third', + ])); + const overlaid = overlayContactOnVCard(document, { + uid: 'custom-contact', + displayName: 'Custom Contact', + emails: [], + phones: [], + additionalFields: [{ + id: 'custom-stable-id', + kind: 'custom-text', + label: 'Favorite color', + value: 'Blue', + }], + }); + const custom = overlaid.properties.find(property => property.name === 'X-MAILFLOW-CUSTOM'); + const label = overlaid.properties.find(property => ( + property.name === 'X-ABLABEL' && property.group === custom.group + )); + + expect(custom).toEqual({ + group: 'item2', + name: 'X-MAILFLOW-CUSTOM', + params: [{ name: 'X-MAILFLOW-ID', values: ['custom-stable-id'] }], + rawValue: 'Blue', + }); + expect(label).toEqual({ + group: 'item2', + name: 'X-ABLABEL', + params: [], + rawValue: 'Favorite color', + }); + expect(serializeVCardDocument(overlaid)).toContain( + 'item2.X-MAILFLOW-CUSTOM;X-MAILFLOW-ID=custom-stable-id:Blue\r\n' + + 'item2.X-ABLABEL:Favorite color\r\n', + ); + }); + + it('rejects new custom text without an explicit label before hashing or overlay', () => { + const document = parseVCardDocument(vcard([ + 'UID:unlabeled-custom', + 'FN:Unlabeled Custom', + ])); + const contact = { + ...contactFromVCardDocument(document), + additionalFields: [{ + id: 'unlabeled-custom-field', + kind: 'custom-text', + label: '', + value: 'Blue', + }], + }; + + expect(() => localContactHash(contact)) + .toThrow('MailFlow custom text field requires a label'); + expect(() => overlayContactOnVCard(document, contact)) + .toThrow('MailFlow custom text field requires a label'); + }); + + it('allocates new custom text away from stale occupied metadata groups', () => { + const document = parseVCardDocument(vcard([ + 'UID:stale-custom-group', + 'FN:Stale Custom Group', + 'item1.X-REMOTE:opaque', + 'item1.X-ABLABEL:Remote label', + ])); + const contact = { + ...contactFromVCardDocument(document), + additionalFields: [{ + id: 'new-custom-field', + kind: 'custom-text', + label: 'Favorite color', + value: 'Blue', + vcard: { + group: 'item1', + name: 'X-MAILFLOW-CUSTOM', + params: [{ name: 'X-VENDOR', values: ['keep'] }], + }, + }], + }; + + const overlaid = overlayContactOnVCard(document, contact); + const serialized = serializeVCardDocument(overlaid); + const projected = contactFromVCardDocument(parseVCardDocument(serialized)); + const custom = overlaid.properties.find(property => property.name === 'X-MAILFLOW-CUSTOM'); + + expect(custom).toEqual({ + group: 'item2', + name: 'X-MAILFLOW-CUSTOM', + params: [ + { name: 'X-VENDOR', values: ['keep'] }, + { name: 'X-MAILFLOW-ID', values: ['new-custom-field'] }, + ], + rawValue: 'Blue', + }); + expect(overlaid.properties).toEqual(expect.arrayContaining([ + { + group: 'item1', + name: 'X-ABLABEL', + params: [], + rawValue: 'Remote label', + }, + { + group: 'item2', + name: 'X-ABLABEL', + params: [], + rawValue: 'Favorite color', + }, + ])); + expect(projected.additionalFields).toEqual([ + expect.objectContaining({ + kind: 'custom-text', + label: 'Favorite color', + value: 'Blue', + }), + ]); + }); + + it.each([ + ['3.0', '49.1;-123.1'], + ['4.0', 'geo:49.1,-123.1'], + ])('serializes GEO values for vCard %s', (version, rawValue) => { + const document = parseVCardDocument(vcard([], version)); + const overlaid = overlayContactOnVCard(document, { + uid: `geo-${version}`, + displayName: 'Geo Contact', + emails: [], + phones: [], + additionalFields: [{ + id: 'geo-field', + kind: 'geo', + label: 'Location', + value: { latitude: 49.1, longitude: -123.1 }, + }], + }); + const serialized = serializeVCardDocument(overlaid); + const geo = overlaid.properties.find(property => property.name === 'GEO'); + + expect(geo.rawValue).toBe(rawValue); + expect(serialized).toContain(`GEO;X-MAILFLOW-ID=geo-field:${rawValue}\r\n`); + expect(contactFromVCardDocument(parseVCardDocument(serialized)).additionalFields[0].value) + .toEqual({ latitude: 49.1, longitude: -123.1 }); + }); + + it('matches Additional groups case-insensitively while preserving retained spelling', () => { + const mixedCase = parseVCardDocument(vcard([ + 'Item1.URL:https://first.example.test', + 'item1.URL:https://second.example.test', + 'ITEM1.X-ABLabel:Website', + ])); + const lowerCase = parseVCardDocument(vcard([ + 'item1.URL:https://first.example.test', + 'item1.URL:https://second.example.test', + 'item1.X-ABLabel:Website', + ])); + const contact = contactFromVCardDocument(mixedCase); + const lowerContact = contactFromVCardDocument(lowerCase); + + expect(contact.additionalFields.map(field => field.label)).toEqual(['Website', 'Website']); + expect(contact.additionalFields.map(field => field.id)) + .toEqual(lowerContact.additionalFields.map(field => field.id)); + + const overlaid = overlayContactOnVCard(mixedCase, { + ...contact, + additionalFields: contact.additionalFields.map(field => ({ + ...field, + label: 'Portfolio', + })), + }); + const urls = overlaid.properties.filter(property => property.name === 'URL'); + const labels = overlaid.properties.filter(property => property.name === 'X-ABLABEL'); + + expect(urls.map(property => property.group)).toEqual(['Item1', 'item1']); + expect(labels).toEqual([{ + group: 'Item1', + name: 'X-ABLABEL', + params: [], + rawValue: 'Portfolio', + }]); + expect(contactFromVCardDocument(overlaid).additionalFields.map(field => field.label)) + .toEqual(['Portfolio', 'Portfolio']); + }); + + it('reorders retained Additional rows in owned target order', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://one.example.test', + 'item1.X-ABLabel:One', + 'item2.URL:https://two.example.test', + 'item2.X-ABLabel:Two', + ])); + const contact = contactFromVCardDocument(document); + const edited = { + ...contact, + additionalFields: [...contact.additionalFields].reverse(), + }; + + const projected = contactFromVCardDocument( + parseVCardDocument(serializeVCardDocument(overlayContactOnVCard(document, edited))), + ); + + expect(projected.additionalFields.map(({ value, label }) => ({ value, label }))).toEqual([ + { value: 'https://two.example.test', label: 'Two' }, + { value: 'https://one.example.test', label: 'One' }, + ]); + expect(localContactHash(projected)).toBe(localContactHash(edited)); + }); + + it('splits one retained row before changing a shared-group label', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://one.example.test', + 'item1.URL:https://two.example.test', + 'item1.X-ABLabel:Shared', + ])); + const contact = contactFromVCardDocument(document); + const edited = structuredClone(contact); + edited.additionalFields[1].label = 'Second'; + + const overlaid = overlayContactOnVCard(document, edited); + const projected = contactFromVCardDocument( + parseVCardDocument(serializeVCardDocument(overlaid)), + ); + const urls = overlaid.properties.filter(property => property.name === 'URL'); + + expect(projected.additionalFields.map(field => field.label)).toEqual(['Shared', 'Second']); + expect(urls[0].group).toBe('item1'); + expect(urls[1].group).not.toBe('item1'); + }); + + it('accepts an empty standard Additional label and drops the custom label', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://example.test', + 'item1.X-ABLabel:Website', + ])); + const contact = contactFromVCardDocument(document); + contact.additionalFields[0].label = ''; + + // A typed field maps to a real property (URL) and only needs a label for + // X-ABLABEL grouping, so a blank label clears the custom label instead of failing. + expect(() => localContactHash(contact)).not.toThrow(); + const overlaid = overlayContactOnVCard(document, contact); + expect(overlaid.properties.some(property => property.name === 'X-ABLABEL')).toBe(false); + const projected = contactFromVCardDocument( + parseVCardDocument(serializeVCardDocument(overlaid)), + ); + expect(projected.additionalFields).toEqual([ + expect.objectContaining({ kind: 'url', label: 'URL', value: 'https://example.test' }), + ]); + }); + + it('projects current Additional kinds and labels after overlay', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://existing.example.test', + 'item1.X-ABLabel:Website', + 'URL:https://change.example.test', + ])); + const contact = contactFromVCardDocument(document); + contact.additionalFields[1] = { + ...contact.additionalFields[1], + kind: 'role', + label: 'Portfolio', + value: 'Editor', + }; + contact.additionalFields.push({ + id: 'new-nickname', + kind: 'nickname', + label: 'Alias', + value: 'JD', + }); + + const projected = contactFromVCardDocument(overlayContactOnVCard(document, contact)); + + expect(projected.additionalFields.map(({ kind, label, value, vcard: metadata }) => ({ + kind, + label, + value, + name: metadata.name, + }))).toEqual([ + { kind: 'url', label: 'Website', value: 'https://existing.example.test', name: 'URL' }, + { kind: 'role', label: 'Portfolio', value: 'Editor', name: 'ROLE' }, + { kind: 'nickname', label: 'Alias', value: 'JD', name: 'NICKNAME' }, + ]); + }); + + it('serializes new URL and IMPP Additional values as URI syntax', () => { + const document = parseVCardDocument(vcard([ + 'UID:uri-additional', + 'FN:URI Additional', + ], '4.0')); + const contact = { + ...contactFromVCardDocument(document), + additionalFields: [ + { + id: 'new-url', + kind: 'url', + label: 'Portfolio', + value: 'https://example.test/a,b;c?x=1,2', + }, + { + id: 'new-impp', + kind: 'im', + label: 'Chat', + value: { protocol: 'xmpp', handle: 'user,name;tag@example.test' }, + }, + ], + }; + + const serialized = serializeVCardDocument(overlayContactOnVCard(document, contact)); + const projected = contactFromVCardDocument(parseVCardDocument(serialized)); + + expect(serialized).toContain( + 'URL;X-MAILFLOW-ID=new-url:https://example.test/a,b;c?x=1,2\r\n', + ); + expect(serialized).toContain( + 'IMPP;X-MAILFLOW-ID=new-impp:xmpp:user,name;tag@example.test\r\n', + ); + expect(projected.additionalFields.map(field => field.value)).toEqual([ + 'https://example.test/a,b;c?x=1,2', + { protocol: 'xmpp', handle: 'user,name;tag@example.test' }, + ]); + }); +}); + +describe('semantic vCard hash', () => { + it('canonicalizes parameter order and repeated/list-equivalent forms without mutating syntax', () => { + const ordered = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK;PREF=1:jane@example.test', + ])); + const reordered = parseVCardDocument(vcard([ + 'EMAIL;PREF=1;TYPE=WORK:jane@example.test', + ])); + const repeated = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK;TYPE=INTERNET:jane@example.test', + ])); + const listed = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK,INTERNET:jane@example.test', + ])); + const changed = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK;PREF=2:jane@example.test', + ])); + const retainedSerializations = [ordered, reordered, repeated, listed] + .map(serializeVCardDocument); + + expect(semanticVCardHash(reordered)).toBe(semanticVCardHash(ordered)); + expect(semanticVCardHash(listed)).toBe(semanticVCardHash(repeated)); + expect(semanticVCardHash(changed)).not.toBe(semanticVCardHash(ordered)); + expect([ordered, reordered, repeated, listed].map(serializeVCardDocument)) + .toEqual(retainedSerializations); + expect(retainedSerializations[0]).toContain('TYPE=WORK;PREF=1'); + expect(retainedSerializations[1]).toContain('PREF=1;TYPE=WORK'); + expect(retainedSerializations[2]).toContain('TYPE=WORK;TYPE=INTERNET'); + expect(retainedSerializations[3]).toContain('TYPE=WORK,INTERNET'); + }); + + it('canonicalizes TYPE value order without sorting ordered parameter values', () => { + const first = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK,INTERNET;X-ORDER=one,two:jane@example.test', + ])); + const typeReordered = parseVCardDocument(vcard([ + 'EMAIL;TYPE=INTERNET,WORK;X-ORDER=one,two:jane@example.test', + ])); + const orderedValueChanged = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK,INTERNET;X-ORDER=two,one:jane@example.test', + ])); + + expect(semanticVCardHash(typeReordered)).toBe(semanticVCardHash(first)); + expect(semanticVCardHash(orderedValueChanged)).not.toBe(semanticVCardHash(first)); + }); + + it('canonicalizes complete property-group alpha-renaming', () => { + const first = parseVCardDocument(vcard([ + 'item1.URL:https://example.test', + 'item1.X-ABLabel:Website', + ])); + const renamed = parseVCardDocument(vcard([ + 'item7.URL:https://example.test', + 'item7.X-ABLabel:Website', + ])); + const relationChanged = parseVCardDocument(vcard([ + 'item7.URL:https://example.test', + 'item8.X-ABLabel:Website', + ])); + + expect(semanticVCardHash(renamed)).toBe(semanticVCardHash(first)); + expect(semanticVCardHash(relationChanged)).not.toBe(semanticVCardHash(first)); + }); + + it('ignores formatting-only rewrites and protected metadata', () => { + const first = parseVCardDocument([ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'PRODID:server-one', + 'REV:20260101T000000Z', + 'FN:Jane Doe', + 'EMAIL;TYPE="WORK,INTERNET":jane@example.test', + 'NOTE:Line one\\NLine two', + 'END:VCARD', + '', + ].join('\r\n')); + const second = parseVCardDocument([ + 'begin:vcard', + 'version:3.0', + 'prodid:server-two', + 'rev:20261231T235959Z', + 'fn:Jane Doe', + 'email;type=work,internet:jane@example.test', + 'note:Line one\\nLine two', + 'end:vcard', + '', + ].join('\n')); + + expect(semanticVCardHash(first)).toMatch(/^[a-f0-9]{64}$/); + expect(semanticVCardHash(second)).toBe(semanticVCardHash(first)); + }); + + it('changes for unknown-property, photo-only, and photo-removal changes', () => { + const base = parseVCardDocument(vcard([ + 'UID:semantic', + 'FN:Semantic', + 'X-REMOTE:value-a', + 'PHOTO;ENCODING=b;TYPE=PNG:AQID', + ])); + const unknownChanged = parseVCardDocument(vcard([ + 'UID:semantic', + 'FN:Semantic', + 'X-REMOTE:value-b', + 'PHOTO;ENCODING=b;TYPE=PNG:AQID', + ])); + const photoChanged = parseVCardDocument(vcard([ + 'UID:semantic', + 'FN:Semantic', + 'X-REMOTE:value-a', + 'PHOTO;ENCODING=b;TYPE=PNG:BAUG', + ])); + const photoRemoved = parseVCardDocument(vcard([ + 'UID:semantic', + 'FN:Semantic', + 'X-REMOTE:value-a', + ])); + + expect(semanticVCardHash(unknownChanged)).not.toBe(semanticVCardHash(base)); + expect(semanticVCardHash(photoChanged)).not.toBe(semanticVCardHash(base)); + expect(semanticVCardHash(photoRemoved)).not.toBe(semanticVCardHash(base)); + }); + + it('canonicalizes base64 data-URI metadata and decoded bytes', () => { + const first = parseVCardDocument(vcard([ + 'PHOTO:data:IMAGE/PNG;BASE64,AQID', + ], '4.0')); + const second = parseVCardDocument(vcard([ + 'PHOTO:data:image/png;base64,AQ ID', + ], '4.0')); + + expect(semanticVCardHash(second)).toBe(semanticVCardHash(first)); + }); + + it('canonicalizes percent-encoded PHOTO bytes independent of hex case', () => { + const first = parseVCardDocument(vcard([ + 'PHOTO:data:image/png,%89PNG%0D%0A', + ], '4.0')); + const second = parseVCardDocument(vcard([ + 'PHOTO:data:IMAGE/PNG,%89PNG%0d%0a', + ], '4.0')); + + expect(semanticVCardHash(second)).toBe(semanticVCardHash(first)); + }); +}); + +describe('local contact hash', () => { + const camelContact = () => ({ + uid: 'stable-uid', + displayName: null, + firstName: 'Jane\r\nQ.', + lastName: 'Doe', + emails: [ + { value: ' Jane.Doe@Example.Test ', type: 'Work', primary: true }, + { value: 'other@example.test', type: 'OTHER', primary: false }, + ], + phones: [{ value: '+1 555 123 4567', type: 'CELL' }], + organization: 'Example Corp', + notes: 'Line one\r\nLine two', + photoData: 'data:IMAGE/PNG;base64,QUJDRA==', + additionalFields: [{ + id: 'field-stable-1', + kind: 'CUSTOM-TEXT', + label: 'Favorite\r\ncolor', + value: 'Blue', + vcard: { + name: 'X-MAILFLOW-CUSTOM', + group: 'item1', + params: [{ values: ['one', 'two'], name: 'TYPE' }], + }, + }], + }); + + it('matches database/API naming shapes and harmless object-key ordering', () => { + const api = camelContact(); + const database = { + id: 'database-id', + address_book_id: 'book-id', + uid: 'stable-uid', + display_name: '', + first_name: 'Jane\nQ.', + last_name: 'Doe', + emails: [ + { primary: 1, type: 'work', value: 'jane.doe@example.test' }, + { primary: 0, type: 'other', value: 'other@example.test' }, + ], + phones: [{ type: 'mobile', value: '+15551234567' }], + organization: 'Example Corp', + notes: 'Line one\nLine two', + photo_data: 'data:image/png;base64,QUJD\nRA==', + additional_fields: [{ + value: 'Blue', + label: 'Favorite\ncolor', + kind: 'custom-text', + id: 'field-stable-1', + vcard: { + params: [{ name: 'type', values: ['ONE', 'TWO'] }], + group: 'item1', + name: 'x-mailflow-custom', + }, + }], + etag: 'ignored', + created_at: 'ignored', + updated_at: 'ignored', + send_count: 999, + is_auto: true, + }; + + expect(localContactHash(api)).toMatch(/^[a-f0-9]{64}$/); + expect(localContactHash(database)).toBe(localContactHash(api)); + }); + + it('normalizes null and empty owned scalar representations', () => { + const first = camelContact(); + const second = { + ...camelContact(), + displayName: '', + organization: null, + }; + first.organization = ''; + + expect(localContactHash(second)).toBe(localContactHash(first)); + }); + + it('ignores incidental persistence keys inside Additional fields', () => { + const original = camelContact(); + const withPersistence = structuredClone(original); + Object.assign(withPersistence.additionalFields[0], { + databaseId: 'row-123', + createdAt: '2026-07-11T00:00:00Z', + is_auto: true, + }); + Object.assign(withPersistence.additionalFields[0].vcard, { + databaseId: 'metadata-row-123', + etag: 'ignored', + }); + withPersistence.additionalFields[0].vcard.params[0].databaseId = 'parameter-row-123'; + + expect(localContactHash(withPersistence)).toBe(localContactHash(original)); + }); + + it('hashes only owned Additional id, kind, label, and value data', () => { + const original = camelContact(); + const syntaxChanged = structuredClone(original); + syntaxChanged.additionalFields[0].vcard = { + group: 'remote-group', + name: 'X-REMOTE-SYNTAX', + params: [ + { name: 'PREF', values: ['1'] }, + { name: 'X-VENDOR', values: ['opaque'] }, + ], + }; + + expect(localContactHash(syntaxChanged)).toBe(localContactHash(original)); + }); + + it('canonicalizes supported local photo MIME identity and decoded bytes', () => { + const paddedJpeg = { ...camelContact(), photoData: 'data:image/jpeg;base64,AQI=' }; + const aliasWithWhitespace = { + ...camelContact(), + photoData: 'data:IMAGE/JPG;base64,A Q I', + }; + const differentBytes = { ...camelContact(), photoData: 'data:image/jpeg;base64,AQM=' }; + const sameBytesPng = { ...camelContact(), photoData: 'data:image/png;base64,AQI=' }; + + expect(localContactHash(aliasWithWhitespace)).toBe(localContactHash(paddedJpeg)); + expect(localContactHash(differentBytes)).not.toBe(localContactHash(paddedJpeg)); + expect(localContactHash(sameBytesPng)).not.toBe(localContactHash(paddedJpeg)); + }); + + it('canonicalizes supported percent-encoded local photo bytes', () => { + const upper = { ...camelContact(), photoData: 'data:image/png,%89PNG%0D%0A' }; + const lower = { ...camelContact(), photoData: 'data:IMAGE/PNG,%89PNG%0d%0a' }; + const changed = { ...camelContact(), photoData: 'data:image/png,%89PNG%0D%0B' }; + + expect(localContactHash(lower)).toBe(localContactHash(upper)); + expect(localContactHash(changed)).not.toBe(localContactHash(upper)); + }); + + it.each([ + ['uid', contact => { contact.uid = 'changed'; }], + ['display name', contact => { contact.displayName = 'Changed'; }], + ['first name', contact => { contact.firstName = 'Changed'; }], + ['last name', contact => { contact.lastName = 'Changed'; }], + ['email', contact => { contact.emails[0].value = 'changed@example.test'; }], + ['email type', contact => { contact.emails[0].type = 'home'; }], + ['email primary flag', contact => { contact.emails[0].primary = false; }], + ['email order', contact => { contact.emails.reverse(); }], + ['phone', contact => { contact.phones[0].value = '+15550000000'; }], + ['phone type', contact => { contact.phones[0].type = 'home'; }], + ['organization', contact => { contact.organization = 'Changed'; }], + ['notes', contact => { contact.notes = 'Changed'; }], + ['photo', contact => { contact.photoData = 'data:image/png;base64,AQID'; }], + ['Additional value', contact => { contact.additionalFields[0].value = 'Red'; }], + ['Additional label', contact => { contact.additionalFields[0].label = 'Other'; }], + ['Additional kind', contact => { contact.additionalFields[0].kind = 'url'; }], + ['Additional stable ID', contact => { contact.additionalFields[0].id = 'changed-id'; }], + ])('changes when the owned %s changes', (_field, mutate) => { + const original = camelContact(); + const changed = structuredClone(original); + mutate(changed); + + expect(localContactHash(changed)).not.toBe(localContactHash(original)); + }); +}); + +describe('vCard property validation and normalization regressions', () => { + it.each([ + ['parsing', params => parseVCardDocument(vcard([ + foldAsciiLine(`PHOTO;${params}:${Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64')}`), + ]))], + ['serialization', params => serializeVCardDocument({ + version: '3.0', + properties: [{ + group: null, + name: 'PHOTO', + params: params.split(';').map(parameter => { + const [name, value] = parameter.split('='); + return { name, values: [value] }; + }), + rawValue: Buffer.alloc(MAX_PHOTO_BYTES + 1, 0xa5).toString('base64'), + }], + })], + ])('uses the first repeated PHOTO VALUE during %s', (_case, run) => { + expect(() => run('VALUE=binary;VALUE=uri;ENCODING=b')) + .toThrow('vCard exceeds the 512 KiB photo limit'); + expect(() => run('VALUE=uri;VALUE=binary;ENCODING=b')) + .toThrow('vCard exceeds the 64 KiB unfolded line limit'); + }); + + it.each([ + ['empty', [ + 'item1.URL;X-MAILFLOW-ID=:https://one.example.test', + 'item1.X-ABLabel:One', + ], 'vCard contains an invalid MailFlow Additional field ID'], + ['multiple values on one property', [ + 'item1.URL;X-MAILFLOW-ID=one,two:https://one.example.test', + 'item1.X-ABLabel:One', + ], 'vCard contains an invalid MailFlow Additional field ID'], + ['duplicate', [ + 'item1.URL;X-MAILFLOW-ID=duplicate:https://one.example.test', + 'item1.X-ABLabel:One', + 'item2.URL;X-MAILFLOW-ID=duplicate:https://two.example.test', + 'item2.X-ABLabel:Two', + ], 'vCard contains duplicate MailFlow Additional field IDs'], + ])('rejects %s persisted Additional IDs during projection', (_case, lines, error) => { + const document = parseVCardDocument(vcard(lines)); + const original = structuredClone(document); + + expect(() => contactFromVCardDocument(document)).toThrow(error); + expect(document).toEqual(original); + }); + + it('keeps a valid unique persisted Additional ID byte-identical on unchanged overlay', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL;X-MAILFLOW-ID=unique:https://one.example.test', + 'item1.X-ABLabel:One', + ])); + const contact = contactFromVCardDocument(document); + const originalBytes = serializeVCardDocument(document); + + expect(contact.additionalFields[0].id).toBe('unique'); + expect(overlayContactOnVCard(document, contact)).toEqual(document); + expect(serializeVCardDocument(overlayContactOnVCard(document, contact))) + .toBe(originalBytes); + }); + + it('validates reserved IDs before keeping opaque GEO properties unchanged', () => { + const malformed = parseVCardDocument(vcard([ + 'GEO;X-MAILFLOW-ID=:not-a-coordinate', + ])); + const valid = parseVCardDocument(vcard([ + 'GEO;X-MAILFLOW-ID=opaque-geo:not-a-coordinate', + ])); + const contact = contactFromVCardDocument(valid); + const originalBytes = serializeVCardDocument(valid); + + expect(() => contactFromVCardDocument(malformed)) + .toThrow('vCard contains an invalid MailFlow Additional field ID'); + expect(contact.additionalFields).toEqual([]); + expect(overlayContactOnVCard(valid, contact)).toEqual(valid); + expect(serializeVCardDocument(overlayContactOnVCard(valid, contact))) + .toBe(originalBytes); + }); + + it('keeps an opaque GEO occurrence before a derived same-group GEO byte-identical', () => { + const document = parseVCardDocument(vcard([ + 'item1.GEO:not-a-coordinate', + 'item1.GEO:49.1;-123.1', + 'item1.X-ABLabel:Shared', + ])); + const contact = contactFromVCardDocument(document); + const originalBytes = serializeVCardDocument(document); + + expect(contact.additionalFields).toHaveLength(1); + expect(overlayContactOnVCard(document, contact)).toEqual(document); + expect(serializeVCardDocument(overlayContactOnVCard(document, contact))) + .toBe(originalBytes); + }); + + it('persists a derived GEO identity after a true occurrence shift past opaque GEO', () => { + const document = parseVCardDocument(vcard([ + 'item1.GEO:not-a-coordinate', + 'item1.GEO;X-MAILFLOW-ID=saved:49.1;-123.1', + 'item1.GEO:50.1;-124.1', + 'item1.X-ABLabel:Shared', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields = target.additionalFields.slice(1); + const expectedId = target.additionalFields[0].id; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const retained = overlaid.properties.find(property => ( + property.name === 'GEO' && property.rawValue.startsWith('50.1') + )); + + expect(parameterValuesForTest(retained, 'X-MAILFLOW-ID')).toEqual([expectedId]); + expect(reparsed.additionalFields.map(field => field.id)).toEqual([expectedId]); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it.each([ + ['missing', [ + { kind: 'url', label: 'One', value: 'https://one.example.test' }, + ], 'MailFlow Additional field requires a stable ID'], + ['duplicate', [ + { id: 'duplicate', kind: 'url', label: 'One', value: 'https://one.example.test' }, + { id: 'duplicate', kind: 'url', label: 'Two', value: 'https://two.example.test' }, + ], 'MailFlow Additional field IDs must be unique'], + ])('rejects %s caller Additional IDs before overlay or hashing', ( + _case, + additionalFields, + error, + ) => { + const document = parseVCardDocument(vcard([ + 'UID:caller-id-invariant', + 'FN:Caller ID Invariant', + ])); + const contact = { ...contactFromVCardDocument(document), additionalFields }; + const original = structuredClone(document); + + expect(() => overlayContactOnVCard(document, contact)).toThrow(error); + expect(() => localContactHash(contact)).toThrow(error); + expect(document).toEqual(original); + }); + + it.each([ + ['postal-address', { street: '123 Main' }], + ['im', { handle: 'alice' }], + ])('hash-converges accepted partial %s Additional values', (kind, value) => { + const document = parseVCardDocument(vcard([])); + const target = { + ...contactFromVCardDocument(document), + additionalFields: [{ + id: `partial-${kind}`, + kind, + label: 'Partial', + value, + }], + }; + + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlayContactOnVCard(document, target)), + )); + + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it.each([ + ['HTTP-looking', 'https://example.test/not-base64'], + ['data-looking', 'data:image/jpeg,not-base64'], + ].flatMap(([description, rawValue]) => [ + [description, 'parsing', () => parseVCardDocument(vcard([ + `PHOTO;ENCODING=b;TYPE=JPEG:${rawValue}`, + ]))], + [description, 'serialization', () => serializeVCardDocument({ + version: '3.0', + properties: [{ + group: null, + name: 'PHOTO', + params: [ + { name: 'ENCODING', values: ['b'] }, + { name: 'TYPE', values: ['JPEG'] }, + ], + rawValue, + }], + })], + ]))('honors explicit base64 for %s PHOTO data during %s', ( + _description, + _operation, + run, + ) => { + expect(run).toThrow('vCard PHOTO has invalid base64 data'); + }); + + it('keeps explicit data-looking URI PHOTO values opaque, retained, and unfetched', () => { + const originalFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = () => { + fetchCalled = true; + throw new Error('unexpected fetch'); + }; + + try { + const document = parseVCardDocument(vcard([ + 'PHOTO;VALUE=URI:data:image/png;base64,AQID', + ], '4.0')); + const contact = contactFromVCardDocument(document); + const overlaid = overlayContactOnVCard(document, { ...contact, photoData: null }); + + expect(contact.photoData).toBeNull(); + expect(overlaid).toEqual(document); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it.each([ + ['3.0', 'line-wrapped PNG data URI', 'data:image/png;base64,AQ \r\nID', + 'data:image/png;base64,AQID'], + ['4.0', 'line-wrapped PNG data URI', 'data:image/png;base64,AQ \r\nID', + 'data:image/png;base64,AQID'], + ['3.0', 'line-wrapped raw JPEG base64', 'AQ \r\nID', + 'data:image/jpeg;base64,AQID'], + ['4.0', 'line-wrapped raw JPEG base64', 'AQ \r\nID', + 'data:image/jpeg;base64,AQID'], + ['3.0', 'percent-encoded PNG data URI', 'data:image/png,%89PNG%0D%0A', + 'data:image/png;base64,iVBORw0K'], + ['4.0', 'percent-encoded PNG data URI', 'data:image/png,%89PNG%0D%0A', + 'data:image/png;base64,iVBORw0K'], + ])('canonicalizes owned photo input for vCard %s (%s)', ( + version, + _case, + photoData, + canonicalPhotoData, + ) => { + const document = parseVCardDocument(vcard([], version)); + const contact = { ...contactFromVCardDocument(document), photoData }; + + expect(localContactHash(contact)).toBe(localContactHash({ + ...contact, + photoData: canonicalPhotoData, + })); + + const overlaid = overlayContactOnVCard(document, contact); + const photo = overlaid.properties.find(property => property.name === 'PHOTO'); + const serialized = serializeVCardDocument(overlaid); + const reparsed = contactFromVCardDocument(parseVCardDocument(serialized)); + const payload = photo.rawValue.slice(photo.rawValue.lastIndexOf(',') + 1); + + expect(payload).not.toMatch(/\s/); + expect(localContactHash(reparsed)).toBe(localContactHash(contact)); + }); + + it('persists and projects the stable ID of a newly added custom-text row', () => { + const document = parseVCardDocument(vcard([ + 'UID:new-custom-identity', + 'FN:New Custom Identity', + ])); + const target = { + ...contactFromVCardDocument(document), + additionalFields: [{ + id: 'custom-stable-id', + kind: 'custom-text', + label: 'Favorite color', + value: 'Blue', + }], + }; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const property = overlaid.properties.find(entry => entry.name === 'X-MAILFLOW-CUSTOM'); + + expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual(['custom-stable-id']); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it('persists and projects the stable ID of a newly added URL row', () => { + const document = parseVCardDocument(vcard([ + 'UID:new-url-identity', + 'FN:New URL Identity', + ])); + const target = { + ...contactFromVCardDocument(document), + additionalFields: [{ + id: 'url-stable-id', + kind: 'url', + label: 'Portfolio', + value: 'https://example.test/portfolio', + }], + }; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const property = overlaid.properties.find(entry => entry.name === 'URL'); + + expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual(['url-stable-id']); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it('keeps a retained Additional ID through a kind change', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL;X-VENDOR=keep:https://example.test/profile', + 'item1.X-ABLabel:Profile', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields[0] = { + ...target.additionalFields[0], + kind: 'role', + label: 'Team role', + value: 'Editor', + }; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const property = overlaid.properties.find(entry => entry.name === 'ROLE'); + + expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')) + .toEqual([target.additionalFields[0].id]); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it('keeps both retained Additional IDs when one shared label is split', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://one.example.test', + 'item1.URL:https://two.example.test', + 'item1.X-ABLabel:Shared', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields[1].label = 'Second'; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const properties = overlaid.properties.filter(entry => entry.name === 'URL'); + + expect(parameterValuesForTest(properties[0], 'X-MAILFLOW-ID')).toEqual([]); + expect(parameterValuesForTest(properties[1], 'X-MAILFLOW-ID')) + .toEqual([target.additionalFields[1].id]); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it('persists derived survivors when the first shared-label row is split', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://one.example.test', + 'item1.URL:https://two.example.test', + 'item1.X-ABLabel:Shared', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields[0].label = 'First'; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const properties = overlaid.properties.filter(entry => entry.name === 'URL'); + + expect(properties.map(property => parameterValuesForTest(property, 'X-MAILFLOW-ID'))) + .toEqual(target.additionalFields.map(field => [field.id])); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it.each([ + ['deletes the first', fields => fields.slice(1), ['two']], + ['reorders', fields => [...fields].reverse(), ['two', 'one']], + ])('%s retained rows with persisted IDs', (_case, edit, expectedIds) => { + const document = parseVCardDocument(vcard([ + 'item1.URL;X-MAILFLOW-ID=one:https://one.example.test', + 'item1.URL;X-MAILFLOW-ID=two:https://two.example.test', + 'item1.X-ABLabel:Shared', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields = edit(target.additionalFields); + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + + expect(reparsed.additionalFields.map(field => field.id)).toEqual(expectedIds); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it.each([ + ['deletes a preceding persisted row', fields => fields.slice(1), ['derived']], + ['reorders a preceding persisted row', fields => [...fields].reverse(), ['derived', 'saved']], + ])('%s before a derived survivor without identity drift', (_case, edit, expectedValues) => { + const document = parseVCardDocument(vcard([ + 'item1.URL;X-MAILFLOW-ID=saved:https://saved.example.test', + 'item1.URL:https://derived.example.test', + 'item1.X-ABLabel:Shared', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields = edit(target.additionalFields); + const expectedIds = target.additionalFields.map(field => field.id); + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const urls = overlaid.properties.filter(property => property.name === 'URL'); + + expect(reparsed.additionalFields.map(field => ( + field.value.includes('derived') ? 'derived' : 'saved' + ))).toEqual(expectedValues); + expect(reparsed.additionalFields.map(field => field.id)).toEqual(expectedIds); + expect(urls.find(property => property.rawValue.includes('derived')).params) + .toContainEqual({ name: 'X-MAILFLOW-ID', values: [expectedIds[0]] }); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it('prefers and preserves a persisted Additional ID through value and label edits', () => { + const document = parseVCardDocument(vcard([ + 'item4.URL;X-MAILFLOW-ID=ordinary-stable-id:https://old.example.test', + 'item4.X-ABLabel:Old label', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields[0].label = 'New label'; + target.additionalFields[0].value = 'https://new.example.test'; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const property = overlaid.properties.find(entry => entry.name === 'URL'); + + expect(target.additionalFields[0].id).toBe('ordinary-stable-id'); + expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual(['ordinary-stable-id']); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it('keeps a derived Additional ID through ordinary edits without adding identity syntax', () => { + const document = parseVCardDocument(vcard([ + 'item5.URL:https://old.example.test', + 'item5.X-ABLabel:Old label', + ])); + const target = contactFromVCardDocument(document); + target.additionalFields[0].label = 'New label'; + target.additionalFields[0].value = 'https://new.example.test'; + + const overlaid = overlayContactOnVCard(document, target); + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlaid), + )); + const property = overlaid.properties.find(entry => entry.name === 'URL'); + + expect(parameterValuesForTest(property, 'X-MAILFLOW-ID')).toEqual([]); + expect(additionalIdentityRows(reparsed)).toEqual(additionalIdentityRows(target)); + expect(localContactHash(reparsed)).toBe(localContactHash(target)); + }); + + it('keeps unchanged retained remote rows byte-identical without adding identity syntax', () => { + const raw = vcard([ + 'item7.URL;TYPE=HOME;X-VENDOR=keep:https://example.test/profile', + 'item7.X-ABLabel:Profile', + ]); + const document = parseVCardDocument(raw); + const contact = contactFromVCardDocument(document); + const originalBytes = serializeVCardDocument(document); + const overlaid = overlayContactOnVCard(document, contact); + + expect(overlaid).toEqual(document); + expect(serializeVCardDocument(overlaid)).toBe(originalBytes); + expect(serializeVCardDocument(overlaid)).not.toContain('X-MAILFLOW-ID'); + }); + + it('uses the strict first content delimiter for NOTE and unknown properties', () => { + const document = parseVCardDocument(vcard([ + 'NOTE;X-P=trailing\\:sip:alice@example.test', + 'X-REFERENCE;X-P=trailing\\:webcal:event-id', + ])); + + expect(document.properties).toEqual([ + { + group: null, + name: 'NOTE', + params: [{ name: 'X-P', values: ['trailing\\'] }], + rawValue: 'sip:alice@example.test', + }, + { + group: null, + name: 'X-REFERENCE', + params: [{ name: 'X-P', values: ['trailing\\'] }], + rawValue: 'webcal:event-id', + }, + ]); + }); + + it('rejects vCard 3.0 parameter values that can inject content lines', () => { + const document = { + version: '3.0', + properties: [{ + group: null, + name: 'X-SAFE', + params: [{ name: 'X-LABEL', values: ['safe\r\nX-INJECT:payload'] }], + rawValue: 'value', + }], + }; + + expect(() => serializeVCardDocument(document)) + .toThrow('vCard parameter value contains an invalid character'); + }); + + it('round-trips legal vCard 3.0 HTAB parameter whitespace', () => { + const document = { + version: '3.0', + properties: [{ + group: null, + name: 'X-SAFE', + params: [{ name: 'X-LABEL', values: ['left\tright'] }], + rawValue: 'value', + }], + }; + + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it.each(['\0', '\v', '\f', '\x7f'])( + 'rejects forbidden parameter control %j in both versions', + control => { + for (const version of ['3.0', '4.0']) { + expect(() => serializeVCardDocument({ + version, + properties: [{ + group: null, + name: 'X-SAFE', + params: [{ name: 'X-LABEL', values: [`left${control}right`] }], + rawValue: 'value', + }], + })).toThrow('vCard parameter value contains an invalid character'); + } + }, + ); + + it.each(['BEGIN', 'END', 'VERSION'])( + 'rejects structural %s pseudo-properties before serialization', + name => { + expect(() => serializeVCardDocument({ + version: '4.0', + properties: [{ group: null, name, params: [], rawValue: 'value' }], + })).toThrow('vCard document cannot contain structural properties'); + }, + ); + + it.each(['\0', '\v', '\f', '\x7f'])( + 'rejects forbidden parsed parameter control %j in both versions', + control => { + for (const version of ['3.0', '4.0']) { + expect(() => parseVCardDocument(vcard([ + `X-SAFE;X-LABEL=left${control}right:value`, + ], version))).toThrow('vCard parameter value contains an invalid character'); + } + }, + ); + + it('rejects literal double quotes inside a vCard 3.0 parameter value', () => { + expect(() => parseVCardDocument(vcard([ + 'X-SAFE;X-LABEL=left"middle":value', + ]))).toThrow('vCard parameter value contains an invalid character'); + }); + + it('round-trips a valid quoted vCard 3.0 parameter value', () => { + const document = parseVCardDocument(vcard([ + 'X-SAFE;X-LABEL="left,middle":value', + ])); + + expect(document.properties[0].params).toEqual([ + { name: 'X-LABEL', values: ['left,middle'] }, + ]); + expect(parseVCardDocument(serializeVCardDocument(document))).toEqual(document); + }); + + it.each([ + ['parsing', rawValue => parseVCardDocument(vcard([ + foldAsciiLine(`PHOTO:${rawValue}`), + ], '4.0'))], + ['serialization', rawValue => serializeVCardDocument({ + version: '4.0', + properties: [{ group: null, name: 'PHOTO', params: [], rawValue }], + })], + ])('rejects a PHOTO data carrier without a comma during %s', (_case, run) => { + expect(() => run('data:image/png;base64AQID')) + .toThrow('vCard PHOTO has invalid data URI encoding'); + }); + + it.each([ + ['parsing', rawValue => parseVCardDocument(vcard([ + foldAsciiLine(`PHOTO:${rawValue}`), + ], '4.0'))], + ['serialization', rawValue => serializeVCardDocument({ + version: '4.0', + properties: [{ group: null, name: 'PHOTO', params: [], rawValue }], + })], + ])('does not exempt an oversized malformed PHOTO data carrier during %s', (_case, run) => { + const malformed = 'data:image/png;base64' + 'a'.repeat(MAX_CONTENT_LINE_BYTES); + expect(() => run(malformed)) + .toThrow(/vCard PHOTO has invalid data URI encoding|64 KiB unfolded line limit/); + }); + + it.each([ + 'item1.BEGIN:VCARD', + 'BEGIN;X-P=one:VCARD', + 'item1.END:VCARD', + 'END;X-P=one:VCARD', + 'item1.VERSION:4.0', + 'VERSION;X-P=one:4.0', + ])('rejects parsed structural pseudo-property %s', line => { + expect(() => parseVCardDocument(vcard([line], '4.0'))).toThrow(); + }); + + it('rejects parameterized VERSION instead of silently downgrading it', () => { + expect(() => parseVCardDocument([ + 'BEGIN:VCARD', + 'VERSION;VALUE=text:4.0', + 'X-REFERENCE;X-P=line^nbreak:payload', + 'END:VCARD', + '', + ].join('\r\n'))).toThrow('vCard VERSION parameters are not supported'); + }); + + it('uses the standards delimiter for an unlisted URI scheme after a parameter backslash', () => { + const document = parseVCardDocument(vcard([ + 'IMPP;X-P=trailing\\:sip:alice@example.test', + ])); + + expect(document.properties[0]).toEqual({ + group: null, + name: 'IMPP', + params: [{ name: 'X-P', values: ['trailing\\'] }], + rawValue: 'sip:alice@example.test', + }); + }); + + it('serializes owned vCard 4.0 UID and URI TEL edits with URI delimiters', () => { + const document = parseVCardDocument(vcard([ + 'UID:urn:uuid:old,value;part', + 'TEL;VALUE=uri:tel:+15551234567,9;ext=1', + ], '4.0')); + const contact = contactFromVCardDocument(document); + contact.uid = 'urn:uuid:new,value;part'; + contact.phones[0].value = 'tel:+15551234568,9;ext=2'; + + const serialized = serializeVCardDocument(overlayContactOnVCard(document, contact)); + const projected = contactFromVCardDocument(parseVCardDocument(serialized)); + + expect(serialized).toContain('UID:urn:uuid:new,value;part\r\n'); + expect(serialized).toContain('TEL;VALUE=uri:tel:+15551234568,9;ext=2\r\n'); + expect(projected.uid).toBe(contact.uid); + expect(projected.phones[0].value).toBe(contact.phones[0].value); + }); + + it('requires exactly one ordered vCard envelope and counts structural lines', () => { + const card = vcard(['UID:one']); + expect(() => parseVCardDocument(card + card)).toThrow(/exactly one vCard component/); + expect(() => parseVCardDocument('VERSION:3.0\r\nUID:none\r\n')) + .toThrow(/BEGIN:VCARD/); + expect(() => parseVCardDocument([ + 'BEGIN:VCARD', + ...Array.from({ length: 2500 }, () => 'VERSION:3.0'), + 'END:VCARD', + '', + ].join('\r\n'))).toThrow(/exactly one VERSION property/); + }); + + it.each([ + ['group', 'BAD/GROUP.X-SAFE:value', 'vCard contains an invalid property group'], + ['property', 'BAD/NAME:value', 'vCard contains an invalid property name'], + ['parameter', 'X-SAFE;BAD@PARAM=v:value', 'vCard contains an invalid parameter name'], + ])('rejects an invalid parsed %s token independently', (_token, line, error) => { + expect(() => parseVCardDocument(vcard([line]))).toThrow(error); + }); + + it('rejects deleting an earlier ambiguous retained Additional row before mutation', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://one.example.test', + 'item1.URL:https://two.example.test', + 'item1.X-ABLabel:Shared', + ])); + const projected = contactFromVCardDocument(document); + const edited = { + ...projected, + additionalFields: projected.additionalFields.slice(1), + }; + + expect(() => overlayContactOnVCard(document, edited)).toThrow( + 'MailFlow Additional fields cannot delete an earlier ambiguous retained property', + ); + expect(document.properties.map(property => property.rawValue)).toEqual([ + 'https://one.example.test', + 'https://two.example.test', + 'Shared', + ]); + }); + + it('allows deleting the final ambiguous retained Additional row without identity drift', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://one.example.test', + 'item1.URL:https://two.example.test', + 'item1.X-ABLabel:Shared', + ])); + const projected = contactFromVCardDocument(document); + const edited = { + ...projected, + additionalFields: projected.additionalFields.slice(0, 1), + }; + + const reparsed = contactFromVCardDocument(parseVCardDocument( + serializeVCardDocument(overlayContactOnVCard(document, edited)), + )); + + expect(reparsed.additionalFields.map(({ id, value }) => ({ id, value }))).toEqual( + edited.additionalFields.map(({ id, value }) => ({ id, value })), + ); + expect(localContactHash(reparsed)).toBe(localContactHash(edited)); + }); + + it('uses explicit TEXT instead of vCard 4.0 URI defaults for UID and TEL', () => { + const document = parseVCardDocument(vcard([ + 'UID;VALUE=text:id\\,one', + 'TEL;VALUE=text:call\\,me', + ], '4.0')); + const contact = contactFromVCardDocument(document); + const edited = structuredClone(contact); + edited.uid = 'id,two'; + edited.phones[0].value = 'call,you'; + + const serialized = serializeVCardDocument(overlayContactOnVCard(document, edited)); + const reparsed = contactFromVCardDocument(parseVCardDocument(serialized)); + + expect(contact.uid).toBe('id,one'); + expect(contact.phones[0].value).toBe('call,me'); + expect(serialized).toContain('UID;VALUE=text:id\\,two\r\n'); + expect(serialized).toContain('TEL;VALUE=text:call\\,you\r\n'); + expect(reparsed.uid).toBe(edited.uid); + expect(reparsed.phones[0].value).toBe(edited.phones[0].value); + }); + + it('uses vCard 3.0 URL and IMPP URI defaults in projection, overlay, and hashing', () => { + const upper = parseVCardDocument(vcard([ + 'item1.URL:https://example.test/\\N', + 'item2.IMPP:xmpp:user\\N@example.test', + ])); + const lower = parseVCardDocument(vcard([ + 'item1.URL:https://example.test/\\n', + 'item2.IMPP:xmpp:user\\n@example.test', + ])); + const contact = contactFromVCardDocument(upper); + const edited = structuredClone(contact); + edited.additionalFields[0].value = 'https://example.test/\\N/next'; + edited.additionalFields[1].value.handle = 'user\\Nnext@example.test'; + + const serialized = serializeVCardDocument(overlayContactOnVCard(upper, edited)); + const reparsed = contactFromVCardDocument(parseVCardDocument(serialized)); + + expect(contact.additionalFields.map(field => field.value)).toEqual([ + 'https://example.test/\\N', + { protocol: 'xmpp', handle: 'user\\N@example.test' }, + ]); + expect(semanticVCardHash(upper)).not.toBe(semanticVCardHash(lower)); + expect(serialized).toContain('URL:https://example.test/\\N/next\r\n'); + expect(serialized).toContain('IMPP:xmpp:user\\Nnext@example.test\r\n'); + expect(localContactHash(reparsed)).toBe(localContactHash(edited)); + }); + + it('lets explicit URI override TEXT defaults and normalizes newlines only for TEXT', () => { + const uriUpper = parseVCardDocument(vcard(['TEL;VALUE=uri:tel:123\\N4'])); + const uriLower = parseVCardDocument(vcard(['TEL;VALUE=uri:tel:123\\n4'])); + const textUpper = parseVCardDocument(vcard(['TEL;VALUE=text:line\\Nbreak'], '4.0')); + const textLower = parseVCardDocument(vcard(['TEL;VALUE=text:line\\nbreak'], '4.0')); + + expect(contactFromVCardDocument(uriUpper).phones[0].value).toBe('tel:123\\N4'); + expect(contactFromVCardDocument(textUpper).phones[0].value).toBe('line\nbreak'); + expect(semanticVCardHash(uriUpper)).not.toBe(semanticVCardHash(uriLower)); + expect(semanticVCardHash(textUpper)).toBe(semanticVCardHash(textLower)); + }); + + it('counts accepted blank logical lines at the 1 MiB boundary', () => { + const withBlankLine = size => asciiVCardOfSize(size - 2) + .replace('END:VCARD\r\n', '\r\nEND:VCARD\r\n'); + const atLimit = withBlankLine(MAX_VCARD_BYTES); + const overLimit = withBlankLine(MAX_VCARD_BYTES + 1); + + expect(Buffer.byteLength(atLimit)).toBe(MAX_VCARD_BYTES); + expect(parseVCardDocument(atLimit).properties).toHaveLength(16); + expect(() => parseVCardDocument(overLimit)).toThrow(/1 MiB/); + }); + + it('serializes and reparses an accepted exact-budget document', () => { + const document = parseVCardDocument(asciiVCardOfSize(MAX_VCARD_BYTES)); + const serialized = serializeVCardDocument(document); + + expect(parseVCardDocument(serialized)).toEqual(document); + }); + + it('stops serialization before accessing properties after the document budget fails', () => { + const later = { + group: null, + name: 'X-LATER', + get params() { + throw new Error('later params accessed'); + }, + rawValue: 'never', + }; + + expect(() => serializeVCardDocument({ + version: '4.0', + properties: [{ + group: null, + name: 'PHOTO', + params: [{ name: 'VALUE', values: ['URI'] }], + rawValue: 'https://example.test/' + 'a'.repeat(MAX_VCARD_BYTES), + }, later], + })).toThrow('vCard exceeds the 1 MiB limit'); + }); + + it('rejects ambiguous same-group Additional reordering before mutation', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://one.example.test', + 'item1.URL:https://two.example.test', + 'item1.X-ABLabel:Shared', + ])); + const contact = contactFromVCardDocument(document); + contact.additionalFields.reverse(); + + expect(() => overlayContactOnVCard(document, contact)) + .toThrow('MailFlow Additional fields cannot reorder ambiguous retained properties'); + expect(document.properties.map(property => property.rawValue)).toEqual([ + 'https://one.example.test', + 'https://two.example.test', + 'Shared', + ]); + }); + + it('treats retained whitespace-only group labels as absent without rewriting them', () => { + const document = parseVCardDocument(vcard([ + 'item1.URL:https://example.test', + 'item1.X-ABLabel: ', + ])); + const contact = contactFromVCardDocument(document); + const originalBytes = serializeVCardDocument(document); + + expect(contact.additionalFields[0].label).toBe('URL'); + expect(localContactHash(contact)).toMatch(/^[a-f0-9]{64}$/); + expect(overlayContactOnVCard(document, contact)).toEqual(document); + expect(serializeVCardDocument(overlayContactOnVCard(document, contact))).toBe(originalBytes); + + // Setting a whitespace-only label is treated as clearing it: the retained blank + // X-ABLABEL is dropped rather than rewritten to an empty value. + contact.additionalFields[0].label = ' '; + const overlaid = overlayContactOnVCard(document, contact); + expect(overlaid.properties.some(property => property.name === 'X-ABLABEL')).toBe(false); + expect(contactFromVCardDocument( + parseVCardDocument(serializeVCardDocument(overlaid)), + ).additionalFields).toEqual([ + expect.objectContaining({ kind: 'url', label: 'URL', value: 'https://example.test' }), + ]); + }); + + it('normalizes TEXT newline escapes without collapsing URI escapes', () => { + const upperUri = parseVCardDocument(vcard(['URL:https://example.test/\\N'], '4.0')); + const lowerUri = parseVCardDocument(vcard(['URL:https://example.test/\\n'], '4.0')); + const upperText = parseVCardDocument(vcard(['NOTE:line\\Nbreak'], '4.0')); + const lowerText = parseVCardDocument(vcard(['NOTE:line\\nbreak'], '4.0')); + + expect(semanticVCardHash(upperUri)).not.toBe(semanticVCardHash(lowerUri)); + expect(semanticVCardHash(upperText)).toBe(semanticVCardHash(lowerText)); + }); + + it('canonicalizes supported legacy PHOTO carrier aliases by MIME and decoded bytes', () => { + const jpeg = value => parseVCardDocument(vcard([value])); + const canonical = jpeg('PHOTO;ENCODING=b;TYPE=JPEG:AQI='); + const aliases = jpeg('PHOTO;ENCODING=base64;TYPE=JPG:AQI'); + const differentMime = jpeg('PHOTO;ENCODING=b;TYPE=PNG:AQI='); + const differentBytes = jpeg('PHOTO;ENCODING=b;TYPE=JPEG:AQM='); + + expect(semanticVCardHash(aliases)).toBe(semanticVCardHash(canonical)); + expect(semanticVCardHash(differentMime)).not.toBe(semanticVCardHash(canonical)); + expect(semanticVCardHash(differentBytes)).not.toBe(semanticVCardHash(canonical)); + }); + + it('elides explicit URI defaults from semantic hashes', () => { + const implicit = parseVCardDocument(vcard(['TEL:tel:+15551234567'], '4.0')); + const explicit = parseVCardDocument(vcard([ + 'TEL;VALUE=uri:tel:+15551234567', + ], '4.0')); + + expect(semanticVCardHash(explicit)).toBe(semanticVCardHash(implicit)); + }); + + it('preserves semantic vCard 4 PHOTO TYPE changes in hashes', () => { + const home = parseVCardDocument(vcard([ + 'PHOTO;TYPE=HOME:data:image/png;base64,AQID', + ], '4.0')); + const work = parseVCardDocument(vcard([ + 'PHOTO;TYPE=WORK:data:image/png;base64,AQID', + ], '4.0')); + + expect(semanticVCardHash(work)).not.toBe(semanticVCardHash(home)); + }); + + it('canonicalizes valid raw JPEG base64 in local hashes and rejects invalid data', () => { + const raw = { photoData: 'AQID' }; + const dataUri = { photoData: 'data:image/jpeg;base64,AQID' }; + const document = parseVCardDocument(vcard([])); + const overlaid = overlayContactOnVCard(document, raw); + const projected = contactFromVCardDocument( + parseVCardDocument(serializeVCardDocument(overlaid)), + ); + + expect(localContactHash(raw)).toBe(localContactHash(dataUri)); + expect(localContactHash(projected)).toBe(localContactHash(raw)); + expect(() => localContactHash({ photoData: 'not-valid-***' })) + .toThrow(/invalid base64/); + }); + + it('normalizes IMPP protocol casing in local hashes and serialized Additional values', () => { + const document = parseVCardDocument(vcard([ + 'item1.IMPP:xmpp:CaseSensitiveHandle', + 'item1.X-ABLabel:Chat', + ])); + const lower = contactFromVCardDocument(document); + const upper = structuredClone(lower); + upper.additionalFields[0].value.protocol = 'XMPP'; + const serialized = serializeVCardDocument(overlayContactOnVCard(document, upper)); + const projected = contactFromVCardDocument(parseVCardDocument(serialized)); + + expect(localContactHash(upper)).toBe(localContactHash(lower)); + expect(serialized).toContain('IMPP:xmpp:CaseSensitiveHandle\r\n'); + expect(localContactHash(projected)).toBe(localContactHash(lower)); + }); + + it('set-normalizes duplicate TYPE members in semantic hashes', () => { + const single = parseVCardDocument(vcard(['EMAIL;TYPE=WORK:a@example.test'])); + const repeated = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK;TYPE=WORK:a@example.test', + ])); + const listed = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK,WORK:a@example.test', + ])); + const different = parseVCardDocument(vcard([ + 'EMAIL;TYPE=WORK,HOME:a@example.test', + ])); + + expect(semanticVCardHash(repeated)).toBe(semanticVCardHash(single)); + expect(semanticVCardHash(listed)).toBe(semanticVCardHash(single)); + expect(semanticVCardHash(different)).not.toBe(semanticVCardHash(single)); + }); +}); + +describe('presented ETag tracks the served representation', () => { + it('advances the served ETag when a modeled column changes under the same retained document', () => { + const mapping_vcard = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Old Name\r\nCATEGORIES:VIP\r\nEND:VCARD\r\n'; + const base = { + uid: 'local-uid', display_name: 'Ada Lovelace', first_name: null, last_name: null, + emails: [], phones: [], organization: null, notes: null, photo_data: null, + additional_fields: [], vcard: '', mapping_vcard, + }; + const renamed = { ...base, display_name: 'Grace Hopper' }; + expect(presentedEtag(renamed)).not.toBe(presentedEtag(base)); + }); +}); + +describe('push-destined snapshot never emits a local UID on overlay failure', () => { + // A contact whose Additional-field IDs are duplicated makes overlayContactOnVCard throw. + const malformedContact = () => ({ + uid: 'local-uid', display_name: 'Dup', first_name: null, last_name: null, + emails: [], phones: [], organization: null, notes: null, photo_data: null, + additional_fields: [ + { id: 'dup', kind: 'url', label: '', value: 'https://a.test/' }, + { id: 'dup', kind: 'url', label: '', value: 'https://b.test/' }, + ], + vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:local-uid\r\nFN:Dup\r\nEND:VCARD\r\n', + mapping_vcard: 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:Dup\r\nEND:VCARD\r\n', + }); + + it('re-keys the fallback to the retained remote UID when preserveDocumentUid is set', () => { + const result = presentedVCard(malformedContact(), { preserveDocumentUid: true }); + expect(result).toContain('UID:remote-uid'); + expect(result).not.toContain('UID:local-uid'); + }); + + it('serve-only overlay failure still falls back to the stored local-UID vCard', () => { + expect(presentedVCard(malformedContact(), { preserveDocumentUid: false })) + .toContain('UID:local-uid'); + }); + + it('pushSafeSnapshot re-keys a parseable stored vCard to the retained UID', () => { + const retained = parseVCardDocument('BEGIN:VCARD\r\nVERSION:3.0\r\nUID:remote-uid\r\nFN:X\r\nEND:VCARD\r\n'); + const stored = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:local-uid\r\nFN:X\r\nEND:VCARD\r\n'; + const out = pushSafeSnapshot(stored, retained, new Error('overlay failed')); + expect(out).toContain('UID:remote-uid'); + expect(out).not.toContain('UID:local-uid'); + }); + + it('pushSafeSnapshot fails closed (rethrows) when there is no retained UID to re-key to', () => { + const cause = new Error('overlay failed'); + expect(() => pushSafeSnapshot('BEGIN:VCARD\r\nUID:local-uid\r\nEND:VCARD\r\n', { properties: [] }, cause)) + .toThrow(cause); + }); +}); diff --git a/frontend/src/carddavConflictState.js b/frontend/src/carddavConflictState.js new file mode 100644 index 00000000..02061c5c --- /dev/null +++ b/frontend/src/carddavConflictState.js @@ -0,0 +1,137 @@ +export const CARDDAV_RESOLUTIONS = ['keep-mailflow', 'keep-carddav']; + +export const CONFLICT_FIELD_ORDER = [ + 'displayName', + 'firstName', + 'lastName', + 'emails', + 'phones', + 'organization', + 'notes', + 'additionalFields', + 'photo', +]; + +export const CONFLICT_FIELD_LABELS = { + displayName: 'contacts.fields.displayName', + firstName: 'contacts.fields.firstName', + lastName: 'contacts.fields.lastName', + emails: 'contacts.fields.email', + phones: 'contacts.fields.phone', + organization: 'contacts.fields.organization', + notes: 'contacts.fields.notes', + additionalFields: 'contacts.additional.title', + photo: 'contacts.photo.title', +}; + +function sortedEntries(entries, fields) { + return (entries || []) + .map(entry => Object.fromEntries(fields.map(field => [field, entry?.[field] ?? '']))) + .sort((first, second) => JSON.stringify(first).localeCompare(JSON.stringify(second))); +} + +function normalizedValue(contact, key) { + if (key === 'emails') { + return sortedEntries(contact?.emails, ['value', 'type', 'primary']); + } + if (key === 'phones') { + return sortedEntries(contact?.phones, ['value', 'type']); + } + if (key === 'additionalFields') { + return cloneFields(contact?.additionalFields) + .map(field => Object.fromEntries( + Object.entries(field).map(([fieldKey, value]) => [fieldKey, value ?? '']), + )) + .sort((first, second) => JSON.stringify(first).localeCompare(JSON.stringify(second))); + } + return contact?.[key] ?? ''; +} + +function comparisonCell(side, key) { + if (side?.tombstone) return { kind: 'tombstone' }; + if (key === 'photo') return { kind: 'photo', present: Boolean(side?.hasPhoto) }; + return { kind: 'value', value: normalizedValue(side?.contact, key) }; +} + +export function conflictComparison(conflict) { + return CONFLICT_FIELD_ORDER.map(key => ({ + key, + local: comparisonCell(conflict?.local, key), + remote: comparisonCell(conflict?.remote, key), + })); +} + +export function initialConflictQueueState(conflicts, selectedId = null) { + const countKnown = Array.isArray(conflicts); + const unresolved = (conflicts || []).filter(conflict => conflict.status !== 'resolved'); + const selected = unresolved.some(conflict => conflict.id === selectedId) + ? selectedId + : unresolved[0]?.id ?? null; + return { + conflicts: unresolved, + selectedId: selected, + pendingResolution: null, + errorKey: null, + countKnown, + loadGeneration: 0, + loading: false, + loadError: false, + resolutionApplied: false, + }; +} + +export function beginConflictLoad(state) { + return { + ...state, + loadGeneration: state.loadGeneration + 1, + loading: true, + loadError: false, + }; +} + +export function completeConflictLoad(state, generation, conflicts, selectedId = null) { + if (generation !== state.loadGeneration) return state; + const loaded = initialConflictQueueState(conflicts, selectedId || state.selectedId); + return { + ...loaded, + pendingResolution: state.resolutionApplied ? null : state.pendingResolution, + errorKey: state.resolutionApplied ? null : state.errorKey, + loadGeneration: state.loadGeneration, + }; +} + +export function failConflictLoad(state, generation) { + if (generation !== state.loadGeneration) return state; + return { + ...state, + pendingResolution: state.resolutionApplied ? null : state.pendingResolution, + errorKey: state.resolutionApplied + ? 'contacts.conflicts.resolveFailed' + : state.errorKey, + loading: false, + loadError: true, + resolutionApplied: false, + }; +} + +export function beginConflictResolution(state, resolution) { + if (!CARDDAV_RESOLUTIONS.includes(resolution)) throw new TypeError('Unsupported conflict resolution'); + return { ...state, pendingResolution: resolution, errorKey: null }; +} + +export function failConflictResolution(state) { + return { + ...state, + pendingResolution: null, + errorKey: 'contacts.conflicts.resolveFailed', + resolutionApplied: false, + }; +} + +export function completeConflictResolution(state) { + return { + ...state, + resolutionApplied: true, + }; +} +import { cloneFields } from './contactCarddavState.js'; diff --git a/frontend/src/carddavConflictState.test.js b/frontend/src/carddavConflictState.test.js new file mode 100644 index 00000000..5a009991 --- /dev/null +++ b/frontend/src/carddavConflictState.test.js @@ -0,0 +1,184 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + CARDDAV_RESOLUTIONS, + CONFLICT_FIELD_LABELS, + CONFLICT_FIELD_ORDER, + beginConflictLoad, + beginConflictResolution, + completeConflictLoad, + completeConflictResolution, + conflictComparison, + failConflictLoad, + failConflictResolution, + initialConflictQueueState, +} from './carddavConflictState.js'; +import { api } from './utils/api.js'; + +describe('CardDAV conflict state', () => { + const conflicts = [{ + id: 'conflict-1', + status: 'unresolved', + local: { + tombstone: true, + hasPhoto: false, + contact: null, + }, + remote: { + tombstone: false, + hasPhoto: true, + contact: { + displayName: 'Ada Lovelace', + firstName: 'Ada', + lastName: 'Lovelace', + emails: [ + { value: 'z@example.test', type: 'other', primary: false }, + { value: 'ada@example.test', type: 'work', primary: true }, + ], + phones: [{ value: '+1 555 0100', type: 'mobile' }], + organization: 'Analytical Engines', + notes: 'Remote note', + additionalFields: [{ id: 'site', kind: 'url', label: 'Website', value: 'https://example.test' }], + }, + }, + }]; + + it('orders normalized comparison fields and represents tombstones and photos without payloads', () => { + assert.deepEqual(CONFLICT_FIELD_ORDER, [ + 'displayName', 'firstName', 'lastName', 'emails', 'phones', + 'organization', 'notes', 'additionalFields', 'photo', + ]); + + const rows = conflictComparison(conflicts[0]); + assert.deepEqual(rows.map(row => row.key), CONFLICT_FIELD_ORDER); + assert.ok(rows.every(row => row.local.kind === 'tombstone')); + assert.deepEqual(rows.find(row => row.key === 'emails').remote, { + kind: 'value', + value: [ + { value: 'ada@example.test', type: 'work', primary: true }, + { value: 'z@example.test', type: 'other', primary: false }, + ], + }); + assert.deepEqual(rows.at(-1).remote, { kind: 'photo', present: true }); + assert.equal(JSON.stringify(rows).includes('photoData'), false); + assert.deepEqual(CONFLICT_FIELD_LABELS, { + displayName: 'contacts.fields.displayName', + firstName: 'contacts.fields.firstName', + lastName: 'contacts.fields.lastName', + emails: 'contacts.fields.email', + phones: 'contacts.fields.phone', + organization: 'contacts.fields.organization', + notes: 'contacts.fields.notes', + additionalFields: 'contacts.additional.title', + photo: 'contacts.photo.title', + }); + }); + + it('exposes exactly the two server-supported resolution actions', () => { + assert.deepEqual(CARDDAV_RESOLUTIONS, ['keep-mailflow', 'keep-carddav']); + }); + + it('keeps the selected comparison open with safe copy when resolution fails', () => { + const initial = initialConflictQueueState(conflicts, 'conflict-1'); + const pending = beginConflictResolution(initial, 'keep-carddav'); + assert.equal(pending.pendingResolution, 'keep-carddav'); + + const failed = failConflictResolution(pending); + assert.equal(failed.selectedId, 'conflict-1'); + assert.deepEqual(failed.conflicts, conflicts); + assert.equal(failed.pendingResolution, null); + assert.equal(failed.errorKey, 'contacts.conflicts.resolveFailed'); + assert.equal(JSON.stringify(failed).includes('private remote response'), false); + }); + + it('does not publish an empty conflict count before the first successful load', () => { + assert.equal(initialConflictQueueState(null, 'conflict-1').countKnown, false); + assert.equal(initialConflictQueueState([], 'conflict-1').countKnown, true); + }); + + it('keeps a successful resolution pending until the refreshed queue arrives', () => { + const queue = initialConflictQueueState([ + ...conflicts, + { ...conflicts[0], id: 'conflict-2' }, + ], 'conflict-1'); + const pending = beginConflictResolution(queue, 'keep-carddav'); + const applied = completeConflictResolution(pending); + + assert.equal(applied.pendingResolution, 'keep-carddav'); + assert.equal(applied.resolutionApplied, true); + assert.equal(applied.selectedId, 'conflict-1'); + assert.deepEqual(applied.conflicts, queue.conflicts); + + const loading = beginConflictLoad(applied); + const refreshed = completeConflictLoad(loading, loading.loadGeneration, [ + { ...conflicts[0], id: 'conflict-2' }, + ], 'conflict-1'); + + assert.equal(refreshed.pendingResolution, null); + assert.equal(refreshed.resolutionApplied, false); + assert.equal(refreshed.loading, false); + assert.equal(refreshed.selectedId, 'conflict-2'); + }); + + it('ignores an older conflict load after a newer load has completed', () => { + const initial = initialConflictQueueState(conflicts, 'conflict-1'); + const older = beginConflictLoad(initial); + const newer = beginConflictLoad(older); + const newestConflict = { ...conflicts[0], id: 'conflict-newest' }; + const current = completeConflictLoad( + newer, + newer.loadGeneration, + [newestConflict], + 'conflict-newest', + ); + + assert.equal(completeConflictLoad( + current, + older.loadGeneration, + conflicts, + 'conflict-1', + ), current); + assert.deepEqual(current.conflicts, [newestConflict]); + }); + + it('keeps safe comparison state when the post-resolution refresh fails', () => { + const pending = beginConflictResolution( + initialConflictQueueState(conflicts, 'conflict-1'), + 'keep-mailflow', + ); + const applied = completeConflictResolution(pending); + const loading = beginConflictLoad(applied); + const failed = failConflictLoad(loading, loading.loadGeneration); + + assert.deepEqual(failed.conflicts, conflicts); + assert.equal(failed.selectedId, 'conflict-1'); + assert.equal(failed.pendingResolution, null); + assert.equal(failed.resolutionApplied, false); + assert.equal(failed.loading, false); + assert.equal(failed.loadError, true); + assert.equal(failed.errorKey, 'contacts.conflicts.resolveFailed'); + }); + + it('uses the exact conflict list and resolution API contracts', async () => { + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + return { ok: true, json: async () => ({}) }; + }; + + try { + await api.carddav.getConflicts(); + await api.carddav.resolveConflict('conflict-1', 'keep-mailflow'); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(calls.map(({ url, options }) => [url, options.method]), [ + ['/api/carddav/conflicts', 'GET'], + ['/api/carddav/conflicts/conflict-1/resolve', 'POST'], + ]); + assert.equal(calls[1].options.body, JSON.stringify({ resolution: 'keep-mailflow' })); + }); +}); diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 16638936..32e08f30 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useStore } from '../store/index.js'; import { newAiAction, AI_ACTION_LIMITS } from '../aiActions.js'; @@ -11,6 +11,7 @@ import { NOTIFICATION_SOUNDS, playNotificationSound, playCustomSound, warmUpAudi import { usePushNotifications } from '../hooks/usePushNotifications.js'; import SignatureEditor from './SignatureEditor.jsx'; import GtdZeroPet from './GtdZeroPet.jsx'; +import CardDavConflicts from './CardDavConflicts.jsx'; import { getEffectiveShortcuts, getGroupedActions, ACTION_DEFS, SPECIAL_KEY_LABELS, parseModKey, modLabel } from '../utils/defaultShortcuts.js'; import { DEFAULT_GTD_FOLDERS, GTD_STATES, resolveAccountGtdFolders, diffGtdFolders, findGtdFolderCollisions } from '../utils/gtd.js'; @@ -1856,28 +1857,62 @@ function LayoutsTab() { } // ─── Integrations Tab ──────────────────────────────────────────────────────── -// CardDAV contact sync (e.g. Nextcloud). One-way, read-only pull. +// CardDAV contact sync (e.g. Nextcloud). function CardDavCard() { const { t } = useTranslation(); const [status, setStatus] = useState(null); // null while loading const [expanded, setExpanded] = useState(false); - const [form, setForm] = useState({ serverUrl: '', username: '', password: '', dupMode: 'separate', intervalMin: 60 }); + const [form, setForm] = useState({ serverUrl: '', username: '', password: '', intervalMin: 60 }); const [connecting, setConnecting] = useState(false); const [syncing, setSyncing] = useState(false); const [disconnecting, setDisconnecting] = useState(false); const [error, setError] = useState(''); + const [conflictCount, setConflictCount] = useState(0); + const [showConflicts, setShowConflicts] = useState(false); + const [healthUnavailable, setHealthUnavailable] = useState(false); + + const refreshHealth = useCallback(async () => { + setHealthUnavailable(false); + const [statusResult, conflictResult] = await Promise.allSettled([ + api.carddav.status(), + api.carddav.getConflicts(), + ]); + if (statusResult.status === 'fulfilled') setStatus(statusResult.value); + else { + setStatus(current => current ?? { connected: false }); + setHealthUnavailable(true); + } + if (conflictResult.status === 'fulfilled') setConflictCount(conflictResult.value.conflicts.length); + else setHealthUnavailable(true); + }, []); + + const refreshStatus = useCallback(async () => { + try { setStatus(await api.carddav.status()); } + catch { setHealthUnavailable(true); } + }, []); - useEffect(() => { api.carddav.status().then(setStatus).catch(() => setStatus({ connected: false })); }, []); + useEffect(() => { refreshHealth(); }, [refreshHealth]); const connected = status?.connected; const loading = status === null; + const needsAttention = Boolean(status?.lastError || conflictCount); + const healthLabel = healthUnavailable + ? t('admin.integrations.carddav.healthUnavailable') + : connected + ? t(needsAttention ? 'admin.integrations.carddav.healthNeedsAttention' : 'admin.integrations.carddav.healthHealthy') + : t('admin.integrations.carddav.notConnected'); + const healthColor = connected && !needsAttention && !healthUnavailable + ? '#22c55e' + : healthUnavailable || needsAttention + ? 'var(--red, #f87171)' + : 'var(--text-tertiary)'; const handleConnect = async () => { setConnecting(true); setError(''); try { const s = await api.carddav.connect({ serverUrl: form.serverUrl.trim(), username: form.username.trim(), - password: form.password, dupMode: form.dupMode, intervalMin: Number(form.intervalMin), + password: form.password, intervalMin: Number(form.intervalMin), }); setStatus(s); setForm(f => ({ ...f, password: '' })); } catch (e) { setError(e.message || t('admin.integrations.carddav.connectFailed')); } @@ -1885,13 +1920,13 @@ function CardDavCard() { }; const handleSync = async () => { setSyncing(true); setError(''); - try { const r = await api.carddav.sync(); setStatus(r.status); if (!r.ok && r.error) setError(r.error); } + try { const r = await api.carddav.sync(); setStatus(r.status); if (!r.ok && r.error) setError(r.error); await refreshHealth(); } catch (e) { setError(e.message); } finally { setSyncing(false); } }; const handleDisconnect = async () => { setDisconnecting(true); setError(''); - try { await api.carddav.disconnect(); setStatus({ connected: false }); } + try { await api.carddav.disconnect(); setStatus({ connected: false }); setConflictCount(0); setShowConflicts(false); } catch (e) { setError(e.message); } finally { setDisconnecting(false); } }; @@ -1921,8 +1956,8 @@ function CardDavCard() {
{t('admin.integrations.carddav.title')} {t('todoist.betaLabel')} - - {loading ? '...' : (connected ? t('admin.integrations.carddav.connected') : t('admin.integrations.carddav.notConnected'))} + + {loading ? '...' : healthLabel}
{t('admin.integrations.carddav.description')}
@@ -1946,16 +1981,11 @@ function CardDavCard() { {status.lastError && (
{t('admin.integrations.carddav.syncFailed', { error: status.lastError })}
)} +
+ {t('contacts.conflicts.count', { count: conflictCount })} +
-
- - -
{disconnecting ? t('common.loading') : t('admin.integrations.carddav.disconnect')} + {conflictCount > 0 && ( + + )}
+ {showConflicts && ( + setShowConflicts(false)} + onCountChange={setConflictCount} + onResolved={refreshStatus} + /> + )} ) : ( <> @@ -1980,12 +2022,6 @@ function CardDavCard() { setForm(f => ({ ...f, username: e.target.value }))} placeholder={t('admin.integrations.carddav.userPh')} style={inputStyle} />
setForm(f => ({ ...f, password: e.target.value }))} placeholder={t('admin.integrations.carddav.passPh')} style={inputStyle} />
-
-
{errBox}
+ )} +
+ + {queue.loading &&
{t('common.loading')}
} + {!queue.loading && queue.loadError &&
{t('contacts.conflicts.loadFailed')}
} + {!queue.loading && !queue.loadError && queue.conflicts.length === 0 && ( +
{t('contacts.conflicts.empty')}
+ )} + + {!queue.loading && selected && ( + <> + {queue.conflicts.length > 1 && ( +
+ {queue.conflicts.map((conflict, index) => ( + + ))} +
+ )} + +
+
+
+
+
+ {t('contacts.conflicts.mailflowSide')} + {t('contacts.conflicts.carddavSide')} +
+ {rows.map(row => ( +
+ + {t(CONFLICT_FIELD_LABELS[row.key])} + +
+
+
+ ))} +
+
+ {scrollEdges.start &&
} + {scrollEdges.end &&
} +
+ + {queue.errorKey &&
{t(queue.errorKey)}
} +
+ {CARDDAV_RESOLUTIONS.map(resolution => ( + + ))} +
+ + )} +
+ ); +} + +const primaryButtonStyle = { + padding: '8px 14px', + border: 'none', + borderRadius: 7, + background: 'var(--accent)', + color: 'white', + cursor: 'pointer', + fontSize: 13, + fontWeight: 500, +}; + +const secondaryButtonStyle = { + padding: '7px 12px', + border: '1px solid var(--border)', + borderRadius: 7, + background: 'var(--bg-tertiary)', + color: 'var(--text-primary)', + cursor: 'pointer', + fontSize: 13, +}; + +const noticeStyle = { + padding: 18, + borderRadius: 9, + background: 'var(--bg-secondary)', + color: 'var(--text-tertiary)', + fontSize: 13, +}; + +const errorStyle = { + padding: '10px 12px', + borderRadius: 8, + background: 'var(--red-dim, rgba(248,113,113,0.1))', + border: '1px solid var(--red-border, rgba(248,113,113,0.3))', + color: 'var(--red, #f87171)', + fontSize: 13, +}; + +const comparisonHeaderStyle = { + display: 'grid', + gridTemplateColumns: '140px minmax(220px, 1fr) minmax(220px, 1fr)', + gap: 12, + padding: '10px 12px', + background: 'var(--bg-tertiary)', + borderBottom: '1px solid var(--border)', + fontSize: 12, +}; + +const comparisonRowStyle = { + display: 'grid', + gridTemplateColumns: '140px minmax(220px, 1fr) minmax(220px, 1fr)', + gap: 12, + padding: '10px 12px', + borderBottom: '1px solid var(--border-subtle)', +}; + +const comparisonCellStyle = { + minWidth: 0, + overflowWrap: 'anywhere', + color: 'var(--text-primary)', + fontSize: 13, +}; + +function comparisonFadeStyle(side) { + return { + position: 'absolute', + top: 1, + bottom: 1, + [side]: 1, + width: 36, + pointerEvents: 'none', + borderRadius: side === 'right' ? '0 10px 10px 0' : '10px 0 0 10px', + // A scroll shadow reads on any surface (unlike a fade to the panel colour, which + // vanishes over same-coloured rows) so the off-screen column is always cued. + background: `linear-gradient(to ${side}, rgba(0,0,0,0), rgba(0,0,0,0.38))`, + }; +} diff --git a/frontend/src/components/ContactsPage.jsx b/frontend/src/components/ContactsPage.jsx index 02493f6f..991fc869 100644 --- a/frontend/src/components/ContactsPage.jsx +++ b/frontend/src/components/ContactsPage.jsx @@ -3,6 +3,25 @@ import { useTranslation } from 'react-i18next'; import { api } from '../utils/api.js'; import { useStore } from '../store/index.js'; import { useMobile } from '../hooks/useMobile.js'; +import { + ADDITIONAL_FIELD_KINDS, + additionalFieldInputType, + beginPhotoRead, + canUploadContactPhoto, + completePhotoRead, + contactCarddavState, + contactToForm, + formToContactDraft, + initialPhotoReadState, + invalidatePhotoRead, + newAdditionalField, + removeContactPhoto, + saveFailureState, + shouldRefreshContactAfterResolution, + validateContactPhoto, +} from '../contactCarddavState.js'; +import { formatContactValue, humanizeContactLabel } from '../contactLabels.js'; +import CardDavConflicts from './CardDavConflicts.jsx'; // Deterministic avatar color from a string function avatarColor(str) { @@ -32,20 +51,8 @@ function Avatar({ name, email, size = 36 }) { ); } -function EmptyEmailForm() { - return [{ value: '', type: 'other', primary: true }]; -} - function emptyContact() { - return { - displayName: '', - firstName: '', - lastName: '', - emails: EmptyEmailForm(), - phones: [], - organization: '', - notes: '', - }; + return contactToForm(); } const PAGE_SIZE = 100; @@ -68,6 +75,10 @@ export default function ContactsPage() { const [listError, setListError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [showNew, setShowNew] = useState(false); + const [conflictCount, setConflictCount] = useState(0); + const [showConflicts, setShowConflicts] = useState(false); + const [activeConflictId, setActiveConflictId] = useState(null); + const [photoRead, setPhotoRead] = useState(initialPhotoReadState); // Mobile: 'list' shows the contact list, 'detail' shows contact/form panel const [mobilePanel, setMobilePanel] = useState('list'); const searchTimer = useRef(null); @@ -77,10 +88,20 @@ export default function ContactsPage() { const totalRef = useRef(0); const loadingMoreRef = useRef(false); const searchRef = useRef(''); + const photoReadRef = useRef(photoRead); useEffect(() => { contactsRef.current = contacts; }, [contacts]); useEffect(() => { totalRef.current = total; }, [total]); + const updatePhotoRead = useCallback(next => { + photoReadRef.current = next; + setPhotoRead(next); + }, []); + + const invalidateCurrentPhotoRead = useCallback(() => { + updatePhotoRead(invalidatePhotoRead(photoReadRef.current)); + }, [updatePhotoRead]); + const load = useCallback(async (q = '') => { setLoading(true); setListError(null); @@ -96,7 +117,16 @@ export default function ContactsPage() { } }, []); - useEffect(() => { load(''); }, [load]); + const loadConflictCount = useCallback(async () => { + try { + const result = await api.carddav.getConflicts(); + setConflictCount(result.conflicts.length); + } catch { + // Contact loading remains usable when CardDAV health is temporarily unavailable. + } + }, []); + + useEffect(() => { load(''); loadConflictCount(); }, [load, loadConflictCount]); const onSearchChange = (e) => { const val = e.target.value; @@ -126,6 +156,7 @@ export default function ContactsPage() { }, []); const selectContact = async (c) => { + invalidateCurrentPhotoRead(); setError(null); try { const full = await api.getContact(c.id); @@ -141,6 +172,7 @@ export default function ContactsPage() { }; const startNew = () => { + invalidateCurrentPhotoRead(); setSelected(null); setForm(emptyContact()); setEditing(false); @@ -151,29 +183,27 @@ export default function ContactsPage() { }; const goBackToList = () => { + invalidateCurrentPhotoRead(); setMobilePanel('list'); setSelected(null); setShowNew(false); setEditing(false); + setShowConflicts(false); + setActiveConflictId(null); setError(null); }; const startEdit = () => { if (!selected) return; - setForm({ - displayName: selected.display_name || '', - firstName: selected.first_name || '', - lastName: selected.last_name || '', - emails: (selected.emails?.length ? selected.emails : EmptyEmailForm()), - phones: selected.phones || [], - organization: selected.organization || '', - notes: selected.notes || '', - }); + if (!contactCarddavState(selected).canEdit) return; + invalidateCurrentPhotoRead(); + setForm(contactToForm(selected)); setEditing(true); setError(null); }; const cancelEdit = () => { + invalidateCurrentPhotoRead(); if (showNew) { setShowNew(false); if (isMobile) setMobilePanel('list'); @@ -183,29 +213,14 @@ export default function ContactsPage() { setError(null); }; - const saveContact = async () => { + const submitContact = async (draft, isNew) => { setSaving(true); setError(null); try { - const derivedDisplayName = - form.displayName.trim() || - [form.firstName.trim(), form.lastName.trim()].filter(Boolean).join(' ') || - null; - const payload = { - displayName: derivedDisplayName, - firstName: form.firstName || null, - lastName: form.lastName || null, - emails: form.emails.filter(e => e.value.trim()), - phones: form.phones.filter(p => p.value.trim()), - organization: form.organization || null, - notes: form.notes || null, - }; - let saved; - if (showNew) { - saved = await api.createContact(payload); - } else { - saved = await api.updateContact(selected.id, payload); - } + const payload = formToContactDraft(draft); + const saved = isNew + ? await api.createContact(payload) + : await api.updateContact(selected.id, payload); // Reload list and re-fetch the saved contact before touching UI state, // so that any error here is still shown inside the open form. await load(search); @@ -214,14 +229,36 @@ export default function ContactsPage() { setEditing(false); setSelected(updated); } catch (err) { - setError(err.message); + const failure = saveFailureState(err, draft); + if (failure.view === 'conflict') { + setActiveConflictId(failure.conflictId); + setShowConflicts(true); + setConflictCount(count => Math.max(1, count)); + } else if (failure.view === 'refresh') { + // The write may have applied; MailFlow recovered read-only and reconciles on + // the next sync. Refresh confirmed state (surfacing the existing pending/sync + // marker) and keep the draft so the user can verify and retry if needed. + await load(search); + if (!isNew && selected) { + try { setSelected(await api.getContact(selected.id)); } catch { /* keep prior */ } + } + setError(t(failure.messageKey)); + } else { + setError(failure.error); + } } finally { setSaving(false); } }; + const saveContact = () => { + if (saving || photoReadRef.current.pending) return; + submitContact(form, showNew); + }; + const deleteContact = async () => { if (!selected) return; + if (!contactCarddavState(selected).canDelete) return; setSaving(true); try { await api.deleteContact(selected.id); @@ -230,7 +267,19 @@ export default function ContactsPage() { if (isMobile) setMobilePanel('list'); await load(search); } catch (err) { - setError(err.message); + const failure = saveFailureState(err, null); + if (failure.view === 'conflict') { + setActiveConflictId(failure.conflictId); + setShowConflicts(true); + setConflictCount(count => Math.max(1, count)); + } else if (failure.view === 'refresh') { + // The delete may have applied on the server; refresh the list and show honest + // copy rather than re-issuing a delete that could race the recovery. + await load(search); + setError(t('contacts.carddavWriteUnconfirmed')); + } else { + setError(failure.error); + } } finally { setSaving(false); } @@ -265,6 +314,97 @@ export default function ContactsPage() { ...f, phones: f.phones.filter((_, i) => i !== idx), })); + const setAdditionalField = (id, patch) => setForm(f => ({ + ...f, + additionalFields: f.additionalFields.map(field => ( + field.id === id ? { ...field, ...patch } : field + )), + })); + + const setAdditionalKind = (id, kind) => setForm(f => ({ + ...f, + additionalFields: f.additionalFields.map(field => { + if (field.id !== id) return field; + const replacement = newAdditionalField(kind, () => id); + return { ...replacement, label: field.label }; + }), + })); + + const addAdditionalField = () => setForm(f => ({ + ...f, + additionalFields: [...f.additionalFields, newAdditionalField('custom-text')], + })); + + const removeAdditionalField = id => setForm(f => ({ + ...f, + additionalFields: f.additionalFields.filter(field => field.id !== id), + })); + + const readPhotoFile = (file, onPhotoData) => { + const validationKey = validateContactPhoto(file); + if (validationKey) { + invalidateCurrentPhotoRead(); + setError(t(validationKey)); + return; + } + const reading = beginPhotoRead(photoReadRef.current); + updatePhotoRead(reading); + const generation = reading.generation; + const reader = new FileReader(); + reader.onload = () => { + const completed = completePhotoRead(photoReadRef.current, generation); + if (!completed.accepted) return; + updatePhotoRead(completed.state); + setError(null); + onPhotoData(reader.result); + }; + reader.onerror = () => { + const completed = completePhotoRead(photoReadRef.current, generation); + if (!completed.accepted) return; + updatePhotoRead(completed.state); + setError(t('contacts.photo.readFailed')); + }; + reader.readAsDataURL(file); + }; + + const setPhoto = file => readPhotoFile(file, photoData => { + setForm(f => ({ ...f, photoData, hasPhoto: true })); + }); + + // The read view has no draft: the picked photo goes straight through the edit form's + // save path, so a CardDAV contact keeps its pending/sync markers and remote push. + const uploadDetailPhoto = file => { + if (saving || !canUploadContactPhoto(selected)) return; + readPhotoFile(file, photoData => submitContact( + { ...contactToForm(selected), photoData, hasPhoto: true }, + false, + )); + }; + + const removePhoto = () => { + invalidateCurrentPhotoRead(); + setForm(removeContactPhoto); + }; + + const refreshAfterResolution = async resolvedConflict => { + await load(search); + if (shouldRefreshContactAfterResolution(selected, resolvedConflict)) { + try { + const updated = await api.getContact(selected.id); + invalidateCurrentPhotoRead(); + setSelected(updated); + setEditing(false); + } catch (err) { + if (err.status === 404) { + setSelected(null); + setEditing(false); + } else { + setError(t('contacts.conflicts.refreshFailed')); + } + } + } + }; + const inForm = editing || showNew; // Shared list panel content (used by both mobile and desktop) @@ -299,7 +439,7 @@ export default function ContactsPage() {
{c.display_name || c.primary_email}
@@ -344,7 +484,15 @@ export default function ContactsPage() { // Shared detail / form content const detailPanel = ( <> - {!selected && !showNew && !isMobile && ( + {showConflicts && ( + { setShowConflicts(false); setActiveConflictId(null); }} + onCountChange={setConflictCount} + onResolved={refreshAfterResolution} + /> + )} + {!showConflicts && !selected && !showNew && !isMobile && (
{t('contacts.selectHint')}
)} - {inForm && ( + {!showConflicts && inForm && ( )} - {selected && !inForm && ( + {!showConflicts && selected && !inForm && ( setConfirmDelete(true)} onDeleteConfirm={deleteContact} onDeleteCancel={() => setConfirmDelete(false)} + onOpenConflict={conflictId => { + setActiveConflictId(conflictId); + setShowConflicts(true); + }} t={t} /> )} @@ -434,19 +595,35 @@ export default function ContactsPage() { {mobilePanel === 'list' && ( - +
+ {conflictCount > 0 && ( + + )} + +
)}
@@ -495,20 +672,36 @@ export default function ContactsPage() { }}> {/* Header */}
-
+
{t('contacts.title')} - +
+ {conflictCount > 0 && ( + + )} + +
+ ) : ; + return ( -
- {/* Edit/Delete for editable contacts — out of flow, top-right (fixed width). */} - {!c.read_only && ( -
- {t('common.edit')} - {t('common.delete')} +
+
+
+ {canUploadPhoto ? ( + <> + { + const file = event.target.files?.[0]; + if (file) onSetPhoto(file); + event.target.value = ''; + }} + style={{ display: 'none' }} + /> + + + ) : avatar} +
+

+ {c.display_name || c.primary_email} +

+ {c.organization && ( +
{c.organization}
+ )} + {c.is_auto && ( +
{t('contacts.autoHint')}
+ )} +
- )} -
- -
-

- {c.display_name || c.primary_email} -

- {c.organization && ( -
{c.organization}
- )} - {/* CardDAV badge sits in flow below the name so it can never overlap it, whatever - the badge's translated width. */} - {c.read_only && ( - - {t('contacts.carddavBadge')} - - )} - {c.is_auto && ( -
{t('contacts.autoHint')}
+
+ {carddav.labelKey && ( + )} + {carddav.canEdit && {t('common.edit')}} + {carddav.canDelete && {t('common.delete')}}
@@ -588,7 +836,7 @@ function ContactDetail({ contact: c, confirmDelete, saving, error, onEdit, onDel
)} - {((c.emails?.length > 0) || (c.phones?.length > 0) || c.notes) && ( + {((c.emails?.length > 0) || (c.phones?.length > 0) || c.notes || (c.additional_fields?.length > 0) || c.has_photo) && ( {(c.emails || []).map((e, i) => ( @@ -601,6 +849,14 @@ function ContactDetail({ contact: c, confirmDelete, saving, error, onEdit, onDel ))} {c.notes && {c.notes}} + {(c.additional_fields || []).map(field => ( + + {formatContactValue(field.value, t)} + + ))} + {c.has_photo && !c.photo_data && ( + {t('contacts.photo.present')} + )} )} @@ -621,11 +877,16 @@ function ContactDetail({ contact: c, confirmDelete, saving, error, onEdit, onDel } function ContactForm({ - form, isNew, saving, error, + form, isNew, saving, photoReading, error, onField, onSetEmail, onAddEmail, onRemoveEmail, onSetPhone, onAddPhone, onRemovePhone, + onSetAdditional, onSetAdditionalKind, onAddAdditional, onRemoveAdditional, + onSetPhoto, onRemovePhoto, onSave, onCancel, t, }) { + const photoInputRef = useRef(null); + const [rawLabelIds, setRawLabelIds] = useState(() => new Set()); + const savePending = saving || photoReading; const inputStyle = { width: '100%', boxSizing: 'border-box', padding: '8px 10px', borderRadius: 7, @@ -664,6 +925,52 @@ function ContactForm({ onField('organization', e.target.value)} />
+
+ +
+ {form.photoData ? ( + {t('contacts.photo.previewAlt')} + ) : ( +
+ {form.hasPhoto ? t('contacts.photo.present') : t('contacts.photo.none')} +
+ )} + { + const file = event.target.files?.[0]; + if (file) onSetPhoto(file); + event.target.value = ''; + }} + style={{ display: 'none' }} + /> + + {photoReading && ( + {t('common.loading')} + )} + {form.hasPhoto && ( + + )} +
+
+ {/* Emails */}
@@ -679,7 +986,7 @@ function ContactForm({ onSetPhone(i, 'type', ev.target.value)} - style={{ ...inputStyle, width: 90, padding: '8px 6px' }} + style={{ ...inputStyle, width: 90, cursor: 'pointer' }} > @@ -735,15 +1042,53 @@ function ContactForm({ />
+
+ + {form.additionalFields.map(field => ( +
+
+ + setRawLabelIds(ids => new Set(ids).add(field.id))} + onChange={event => onSetAdditional(field.id, { label: event.target.value })} + /> + +
+ onSetAdditional(field.id, { value })} + t={t} + /> +
+ ))} + +
+