mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
test: check contacts and filters live on Stalwart 0.16 and 1.0
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Live checks of contacts and Sieve filters against a real Stalwart server,
|
||||
* for what the phone writes: contacts with calendar links, vCard imports and
|
||||
* re-imports, address books with cards, the company directory, webmail 1.13+
|
||||
* filter scripts (rule periods, vacation forwarding and audience, exact
|
||||
* address rules) and rules made from a message.
|
||||
*
|
||||
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
||||
* `BULWARK_LIVE_PASS` are set.
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||
* BULWARK_LIVE_USER=e2e-tablet-dark@example.org BULWARK_LIVE_PASS=… \
|
||||
* npx vitest run src/api/__tests__/live-stalwart-contacts.test.ts
|
||||
*
|
||||
* Everything it creates is removed again; the account's Sieve scripts are
|
||||
* put back as they were, the active one included.
|
||||
*
|
||||
* Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0 (2026-10-29).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import {
|
||||
createAddressBook,
|
||||
deleteAddressBook,
|
||||
deleteContacts,
|
||||
getAddressBooks,
|
||||
getContact,
|
||||
createContact,
|
||||
updateContact,
|
||||
} from '../contacts';
|
||||
import {
|
||||
createSieveScript,
|
||||
deleteSieveScript,
|
||||
getSieveAccountId,
|
||||
getSieveCapabilities,
|
||||
getSieveScriptContent,
|
||||
getSieveScripts,
|
||||
updateSieveScript,
|
||||
validateSieveScript,
|
||||
} from '../sieve';
|
||||
import { updateAccountFilters } from '../sieve-rules';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { readVacationFilters, useFilterStore } from '../../stores/filter-store';
|
||||
import { generateVCard, parseVCard } from '../../lib/vcard';
|
||||
import { generateScript } from '../../lib/sieve/generator';
|
||||
import { parseScript } from '../../lib/sieve/parser';
|
||||
import { applyQuickRule, buildPresetRule, toRuleMailbox } from '../../lib/sieve/quick-rules';
|
||||
import { readWebmailFixture, WEBMAIL_FIXTURES } from '../../lib/sieve/__tests__/fixtures/webmail';
|
||||
import { getMailboxes } from '../email';
|
||||
import type { ContactCard } from '../types';
|
||||
import type { SieveScript } from '../../lib/sieve/types';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const live = SERVER && PASS ? describe : describe.skip;
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const SIEVE_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:sieve'];
|
||||
|
||||
let client: JMAPClient;
|
||||
let singleton: JMAPClient;
|
||||
|
||||
/** Stalwart 1.0 advertises JMAP Metadata, 0.16 does not. */
|
||||
const is10 = () => !!client.currentSession?.capabilities?.['urn:ietf:params:jmap:metadata'];
|
||||
|
||||
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
|
||||
(fallback ?? '').replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? ''));
|
||||
|
||||
live('live Stalwart: contacts and filters (0.16 and 1.0)', () => {
|
||||
let bookId = '';
|
||||
const createdCards: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../jmap-client');
|
||||
singleton = mod.jmapClient as unknown as JMAPClient;
|
||||
client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
singleton.restoreSnapshot(client.snapshot());
|
||||
useContactsStore.getState().reset();
|
||||
bookId = (await createAddressBook(`live-contacts-${RUN}`)).id;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdCards.length) await deleteContacts(createdCards).catch(() => undefined);
|
||||
const books = await getAddressBooks().catch(() => []);
|
||||
for (const book of books.filter((b) => b.name.startsWith(`live-contacts-${RUN}`))) {
|
||||
await deleteAddressBook(book.id, undefined, { removeContents: true }).catch(() => undefined);
|
||||
}
|
||||
useContactsStore.getState().reset();
|
||||
}, 60_000);
|
||||
|
||||
describe('contacts', () => {
|
||||
// Stalwart refuses calendarUri as a property ("Invalid property.").
|
||||
it('saves a contact with calendar, free/busy and scheduling links and reads them back', async () => {
|
||||
const card = await createContact({
|
||||
'@type': 'Card', version: '1.0', kind: 'individual',
|
||||
name: { full: `Live Links ${RUN}` },
|
||||
calendarUri: 'https://cal.example.org/anna',
|
||||
freeBusyUri: 'https://cal.example.org/anna/fb',
|
||||
schedulingUri: 'mailto:anna@example.org',
|
||||
} as Partial<ContactCard>, bookId);
|
||||
createdCards.push(card.id);
|
||||
const back = await getContact(card.id);
|
||||
expect(back).toMatchObject({
|
||||
calendarUri: 'https://cal.example.org/anna',
|
||||
freeBusyUri: 'https://cal.example.org/anna/fb',
|
||||
schedulingUri: 'mailto:anna@example.org',
|
||||
});
|
||||
|
||||
await updateContact(card.id, { calendarUri: null, freeBusyUri: null, schedulingUri: null } as never);
|
||||
const cleared = await getContact(card.id);
|
||||
expect(cleared?.calendarUri).toBeUndefined();
|
||||
expect(cleared?.schedulingUri).toBeUndefined();
|
||||
}, 60_000);
|
||||
|
||||
it('imports a vCard with flat address, label, source and birthplace', async () => {
|
||||
const [parsed] = parseVCard([
|
||||
'BEGIN:VCARD', 'VERSION:3.0', `FN:Live Import ${RUN}`, `N:Import ${RUN};Live;;;`,
|
||||
'ADR;TYPE=HOME;LABEL="Hauptstr. 1, Berlin":;;Hauptstr. 1;Berlin;;10115;Germany',
|
||||
'TEL;TYPE=CELL:+49 30 1234',
|
||||
'SOURCE:https://dir.example.org/live.vcf',
|
||||
'CALURI:https://cal.example.org/live',
|
||||
'BDAY:19800102', 'BIRTHPLACE:Hamburg',
|
||||
'END:VCARD',
|
||||
].join('\r\n'));
|
||||
const result = await useContactsStore.getState().importContacts([parsed], bookId);
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.imported).toBe(1);
|
||||
const card = useContactsStore.getState().contacts.find((c) => c.name?.full === `Live Import ${RUN}`);
|
||||
createdCards.push(card!.id);
|
||||
const back = await getContact(card!.id);
|
||||
const address = Object.values(back?.addresses ?? {})[0];
|
||||
expect(address?.components?.find((c) => c.kind === 'locality')?.value).toBe('Berlin');
|
||||
expect(Object.values(back?.phones ?? {})[0]?.features).toEqual({ mobile: true });
|
||||
expect(back?.calendarUri).toBe('https://cal.example.org/live');
|
||||
expect(back?.source).toBe('https://dir.example.org/live.vcf');
|
||||
}, 60_000);
|
||||
|
||||
// Both servers refuse a second card with the same UID; the import used
|
||||
// to count every card of a re-imported export as failed.
|
||||
it('re-imports its own vCard 4.0 export by updating the cards', async () => {
|
||||
const card = await createContact({
|
||||
'@type': 'Card', version: '1.0', kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Live' }, { kind: 'surname', value: `Reimport${RUN}` }], full: `Live Reimport${RUN}` },
|
||||
emails: { e: { address: `reimport-${RUN}@example.org` } },
|
||||
phones: { p: { number: '+1 555 0100', features: { mobile: true } } },
|
||||
} as Partial<ContactCard>, bookId);
|
||||
createdCards.push(card.id);
|
||||
const vcf = generateVCard([{ ...card, notes: { n: { note: 'from the export' } } }]);
|
||||
expect(vcf.startsWith('BEGIN:VCARD\r\nVERSION:4.0\r\n')).toBe(true);
|
||||
expect(vcf).toContain('TEL;TYPE=CELL:');
|
||||
|
||||
// A fresh store: the card is not in the list, the server names it.
|
||||
useContactsStore.getState().reset();
|
||||
const result = await useContactsStore.getState().importContacts(parseVCard(vcf), bookId);
|
||||
expect(result).toMatchObject({ imported: 0, updated: 1, failed: 0 });
|
||||
const back = await getContact(card.id);
|
||||
expect(Object.values(back?.notes ?? {})[0]?.note).toBe('from the export');
|
||||
}, 60_000);
|
||||
|
||||
// 1.0 refuses to destroy a book that holds cards without
|
||||
// onDestroyRemoveContents (addressBookHasContents).
|
||||
it('deletes an address book that still holds contacts', async () => {
|
||||
const book = await createAddressBook(`live-contacts-${RUN}-full`);
|
||||
const card = await createContact({ '@type': 'Card', version: '1.0', name: { full: `Live Book ${RUN}` } } as Partial<ContactCard>, book.id);
|
||||
useContactsStore.setState({ addressBooks: [book], contacts: [card] });
|
||||
await useContactsStore.getState().deleteAddressBook(book.id);
|
||||
expect((await getAddressBooks()).some((b) => b.id === book.id)).toBe(false);
|
||||
expect(await getContact(card.id)).toBeNull();
|
||||
}, 60_000);
|
||||
|
||||
it('lists the directory, saying so when the server keeps it restricted', async () => {
|
||||
useContactsStore.getState().reset();
|
||||
await useContactsStore.getState().fetchDirectory();
|
||||
const s = useContactsStore.getState();
|
||||
if (is10()) {
|
||||
// Directory queries are off by default on 1.0.
|
||||
expect(s.directoryStatus).toBe('restricted');
|
||||
expect(s.directoryQueriesOff).toBe(true);
|
||||
} else {
|
||||
expect(['ready', 'restricted']).toContain(s.directoryStatus);
|
||||
}
|
||||
expect(s.directory.some((e) => e.email.toLowerCase() === USER.toLowerCase())).toBe(true);
|
||||
expect(s.lookupDirectory(USER.split('@')[0]).map((r) => r.email.toLowerCase())).toContain(USER.toLowerCase());
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
let before: Array<SieveScript & { content: string }> = [];
|
||||
let created: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// What the account has, to put back afterwards.
|
||||
const scripts = await getSieveScripts();
|
||||
before = [];
|
||||
for (const s of scripts) before.push({ ...s, content: await getSieveScriptContent(s.blobId) });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
const accountId = getSieveAccountId();
|
||||
const now = await getSieveScripts().catch(() => [] as SieveScript[]);
|
||||
// An active script cannot be destroyed (RFC 9661): switch them all off first.
|
||||
await client.request([['SieveScript/set', { accountId, onSuccessDeactivateScript: true }, '0']], SIEVE_USING)
|
||||
.catch(() => undefined);
|
||||
for (const s of now) {
|
||||
const old = before.find((b) => b.id === s.id);
|
||||
if (!old) await deleteSieveScript(s.id).catch(() => undefined);
|
||||
else if ((await getSieveScriptContent(s.blobId).catch(() => old.content)) !== old.content) {
|
||||
await updateSieveScript(s.id, old.content, false).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
const active = before.find((b) => b.isActive);
|
||||
if (active) {
|
||||
await client.request([['SieveScript/set', { accountId, onSuccessActivateScript: active.id }, '0']], SIEVE_USING)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const after = await getSieveScripts();
|
||||
expect(after.map((s) => [s.id, s.isActive]).sort()).toEqual(before.map((s) => [s.id, s.isActive]).sort());
|
||||
useFilterStore.getState().clearState();
|
||||
}, 60_000);
|
||||
|
||||
it('compiles every webmail fixture', async () => {
|
||||
for (const { file } of WEBMAIL_FIXTURES) {
|
||||
const result = await validateSieveScript(readWebmailFixture(file));
|
||||
expect({ file, ...result }).toEqual({ file, isValid: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// #1152: version 2 opened as raw Sieve, and a save dropped the periods,
|
||||
// the forwarding and the audience.
|
||||
it('edits a webmail 1.13 script as rules and saves it back whole', async () => {
|
||||
const fixture = readWebmailFixture('period-forward-audience.sieve');
|
||||
const script = await createSieveScript(`live-filters-${RUN}`, fixture, true);
|
||||
created = script.id;
|
||||
|
||||
await useFilterStore.getState().selectAccount(null);
|
||||
const s = useFilterStore.getState();
|
||||
expect(s.activeScriptId).toBe(script.id);
|
||||
expect(s.isOpaque).toBe(false);
|
||||
expect(s.rules.map((r) => r.activeFrom)).toEqual(['2026-11-02T07:00:00.000Z', '2026-12-01T00:00:00.000Z']);
|
||||
expect(s.vacationForward?.to).toBe('backup@example.org');
|
||||
expect(s.vacationAudience?.only).toBe('internal');
|
||||
|
||||
await useFilterStore.getState().saveFilters();
|
||||
const saved = (await getSieveScripts()).find((x) => x.id === script.id)!;
|
||||
const content = await getSieveScriptContent(saved.blobId);
|
||||
const parsed = parseScript(fixture);
|
||||
expect(content).toBe(generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
includeVacation: parsed.includeVacation,
|
||||
vacationForward: parsed.vacationForward,
|
||||
vacationAudience: parsed.vacationAudience,
|
||||
extensions: getSieveCapabilities()?.sieveExtensions,
|
||||
}));
|
||||
expect(parseScript(content).vacationForward).toEqual(parsed.vacationForward);
|
||||
|
||||
const vacation = await readVacationFilters();
|
||||
expect(vacation.forward?.to).toBe('backup@example.org');
|
||||
expect(vacation.audience?.domains).toEqual(['example.org', 'example.com']);
|
||||
expect(vacation.forwardAvailable).toBe(true);
|
||||
expect(vacation.audienceAvailable).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('adds a rule made from a message to the top, as an exact address test', async () => {
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox')!;
|
||||
const subject = { senders: [{ email: `sender-${RUN}@example.net` }], domain: null, listId: null };
|
||||
const rule = buildPresetRule({ kind: 'move_sender', mailbox: toRuleMailbox(mailboxes, inbox) }, subject, t, `quick-${RUN}`);
|
||||
expect(await updateAccountFilters((rules) => {
|
||||
const outcome = applyQuickRule(rules, rule);
|
||||
return outcome.kind === 'covered' ? null : outcome.rules;
|
||||
})).toBe(true);
|
||||
|
||||
const active = (await getSieveScripts()).find((x) => x.isActive)!;
|
||||
expect(active.id).toBe(created);
|
||||
const content = await getSieveScriptContent(active.blobId);
|
||||
expect(content).toContain(`if allof(address :is "From" "sender-${RUN}@example.net", not spamtest`);
|
||||
const back = parseScript(content);
|
||||
expect(back.rules[0].id).toBe(`quick-${RUN}`);
|
||||
// The rule went in without losing what the vacation card set.
|
||||
expect(back.vacationForward?.to).toBe('backup@example.org');
|
||||
}, 60_000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user