mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Stalwart does no BIMI, so sender icons were only ever favicons, which anyone controls for any domain. The webmail server now does the receiver side itself at /api/bimi, and avatars ask it only for messages whose own Authentication-Results say dmarc=pass for the exact From domain. - `lib/bimi/`: DMARC policy check (quarantine or reject, pct=100, the parent domain's sp and p for subdomains), the assertion record with fallback to the parent domain, mark certificate validation (chain to pinned DigiCert, GlobalSign and SSL.com VMC roots, BIMI key purpose, validity, SAN, SCTs present, CRL revocation, logo from the logotype extension), SVG Tiny PS checks and rendering to PNG, caching - logos backed only by the DNS record show by default; the admin setting "Require a mark certificate" (`BIMI_REQUIRE_CERTIFICATE`) limits them to VMC and CMC backed ones - a check mark beside the sender's name for certificate-backed logos - avatars try contact photo, BIMI logo, plugin avatar, favicon; the sender warning shield still wins, and logos follow the sender icons setting and the no-third-party-traffic mode - the two-label public suffix list moved to `lib/root-domain.ts` SCT signatures are not verified and only the default selector is used.
335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
import { render, waitFor, act, fireEvent } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { Avatar } from '../avatar';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useContactStore } from '@/stores/contact-store';
|
|
import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
|
|
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
|
|
import type { ContactCard } from '@/lib/jmap/types';
|
|
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
|
|
describe('Avatar', () => {
|
|
it('renders initials from full name', () => {
|
|
const { container } = render(<Avatar name="Alice Smith" />);
|
|
expect(container.textContent).toBe('AS');
|
|
});
|
|
|
|
it('renders two letters from single-word name', () => {
|
|
const { container } = render(<Avatar name="Alice" />);
|
|
expect(container.textContent).toBe('AL');
|
|
});
|
|
|
|
it('renders single letter from email when no name', () => {
|
|
const { container } = render(<Avatar email="bob@example.com" />);
|
|
expect(container.textContent).toBe('B');
|
|
});
|
|
|
|
it('renders "?" when no name or email', () => {
|
|
const { container } = render(<Avatar />);
|
|
expect(container.textContent).toBe('?');
|
|
});
|
|
|
|
it('produces consistent background color for same input', () => {
|
|
const { container: a } = render(<Avatar name="Alice" />);
|
|
const { container: b } = render(<Avatar name="Alice" />);
|
|
const colorA = (a.firstChild as HTMLElement).style.backgroundColor;
|
|
const colorB = (b.firstChild as HTMLElement).style.backgroundColor;
|
|
expect(colorA).toBe(colorB);
|
|
});
|
|
|
|
describe('contact photos', () => {
|
|
const jpeg = () => new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' });
|
|
const fetchBlob = vi.fn(async () => jpeg());
|
|
// Stalwart 1.0 returns the photo as a blob (contact/get.rs, EmbeddedBlobIds).
|
|
const ann: ContactCard = {
|
|
id: 'c1',
|
|
addressBookIds: {},
|
|
accountId: 'a',
|
|
emails: { e0: { address: 'ann@example.com' } },
|
|
media: { photo: { kind: 'photo', blobId: 'dbcAnn', mediaType: 'image/jpeg' } },
|
|
};
|
|
const img = (container: HTMLElement) => container.querySelector('img');
|
|
|
|
beforeEach(() => {
|
|
clearContactPhotoCache();
|
|
fetchBlob.mockReset();
|
|
fetchBlob.mockImplementation(async () => jpeg());
|
|
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:photo');
|
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
useAuthStore.setState({ client: { fetchBlob } as never });
|
|
});
|
|
|
|
afterEach(() => {
|
|
useAuthStore.setState({ client: null });
|
|
useContactStore.setState({ contacts: [] });
|
|
clearContactPhotoCache();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('downloads a Stalwart 1.0 blob photo from the card\'s account', async () => {
|
|
const { container } = render(
|
|
<Avatar name="Ann" contactPhoto={{ blobId: 'dbcAnn', mediaType: 'image/jpeg', accountId: 'shared-1' }} />,
|
|
);
|
|
|
|
await waitFor(() => expect(img(container)?.getAttribute('src')).toBe('blob:photo'));
|
|
expect(fetchBlob).toHaveBeenCalledWith('dbcAnn', 'photo', 'image/jpeg', 'shared-1');
|
|
});
|
|
|
|
it('shows a Stalwart 0.16 data: photo without downloading anything', () => {
|
|
const { container } = render(<Avatar name="Ann" contactPhoto={{ uri: 'data:image/jpeg;base64,/9j/' }} />);
|
|
expect(img(container)?.getAttribute('src')).toBe('data:image/jpeg;base64,/9j/');
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('looks a sender up among the contacts and downloads one copy for every avatar', async () => {
|
|
useContactStore.setState({ contacts: [ann] });
|
|
const { container } = render(
|
|
<>
|
|
<Avatar name="Ann" email="Ann@Example.com" />
|
|
<Avatar name="Ann" email="ann@example.com" />
|
|
</>,
|
|
);
|
|
|
|
await waitFor(() => expect(container.querySelectorAll('img[src="blob:photo"]')).toHaveLength(2));
|
|
expect(fetchBlob).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('borrows no other card\'s photo when the contact has none', () => {
|
|
useContactStore.setState({ contacts: [ann] });
|
|
const { container } = render(<Avatar name="Other Ann" email="ann@example.com" contactPhoto={null} />);
|
|
expect(img(container)).toBeNull();
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('waits until the avatar is on screen', async () => {
|
|
let report: ((entries: Array<{ target: Element; isIntersecting: boolean }>) => void) | undefined;
|
|
vi.stubGlobal('IntersectionObserver', class {
|
|
constructor(callback: typeof report) { report = callback; }
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
});
|
|
const { container } = render(<Avatar name="Ann" contactPhoto={{ blobId: 'dbcAnn', mediaType: 'image/jpeg' }} />);
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
|
|
act(() => report?.([{ target: container.firstChild as Element, isIntersecting: true }]));
|
|
|
|
await waitFor(() => expect(img(container)?.getAttribute('src')).toBe('blob:photo'));
|
|
expect(fetchBlob).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('shows initials when the download fails, after trying once more', async () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
|
|
fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404'));
|
|
const { container } = render(<Avatar name="Ann Lee" contactPhoto={{ blobId: 'dbcGone' }} />);
|
|
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
|
expect(fetchBlob).toHaveBeenCalledTimes(1);
|
|
// The cache asks again after a second, since the server refuses a
|
|
// download over its concurrency limit outright; then it gives up.
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
|
|
expect(fetchBlob).toHaveBeenCalledTimes(2);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
|
|
|
|
expect(fetchBlob).toHaveBeenCalledTimes(2);
|
|
expect(img(container)).toBeNull();
|
|
expect(container.textContent).toBe('AL');
|
|
});
|
|
|
|
it.each([
|
|
[4, 2],
|
|
[8, 4],
|
|
])('downloads photos %i / 2 = %i at a time for a server with that maxConcurrentRequests', async (limit, atOnce) => {
|
|
const pending: Array<() => void> = [];
|
|
fetchBlob.mockImplementation(() => new Promise<Blob>((resolve) => pending.push(() => resolve(jpeg()))));
|
|
useAuthStore.setState({ client: { fetchBlob, getMaxConcurrentRequests: () => limit } as never });
|
|
|
|
render(<>{[0, 1, 2, 3, 4, 5].map((i) => <Avatar key={i} name="Ann" contactPhoto={{ blobId: `dbc${i}` }} />)}</>);
|
|
await act(async () => {});
|
|
|
|
expect(fetchBlob).toHaveBeenCalledTimes(atOnce);
|
|
clearContactPhotoCache();
|
|
pending.forEach((resolve) => resolve());
|
|
});
|
|
|
|
it('falls back to initials when a downloaded photo does not render', async () => {
|
|
const { container } = render(<Avatar name="Ann Lee" contactPhoto={{ blobId: 'dbcSvg', mediaType: 'image/svg+xml' }} />);
|
|
await waitFor(() => expect(img(container)).not.toBeNull());
|
|
|
|
fireEvent.error(img(container)!);
|
|
|
|
expect(img(container)).toBeNull();
|
|
expect(container.textContent).toBe('AL');
|
|
});
|
|
|
|
it('downloads through the client of the card\'s login in multi-account mode', async () => {
|
|
const otherFetch = vi.fn(async () => jpeg());
|
|
const original = useAuthStore.getState().getClientForAccount;
|
|
useAuthStore.setState({
|
|
getClientForAccount: (id: string) => (id === 'other@host' ? { fetchBlob: otherFetch } as never : undefined),
|
|
});
|
|
try {
|
|
const { container } = render(
|
|
<Avatar name="Ann" contactPhoto={{ blobId: 'dbcAnn', mediaType: 'image/jpeg', localAccountId: 'other@host' }} />,
|
|
);
|
|
await waitFor(() => expect(img(container)?.getAttribute('src')).toBe('blob:photo'));
|
|
expect(otherFetch).toHaveBeenCalledTimes(1);
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
} finally {
|
|
useAuthStore.setState({ getClientForAccount: original });
|
|
}
|
|
});
|
|
|
|
it("asks no other server when the card's login has no client, and tries again once it has one", async () => {
|
|
const original = useAuthStore.getState().getClientForAccount;
|
|
const otherFetch = vi.fn(async () => jpeg());
|
|
let connected = false;
|
|
useAuthStore.setState({
|
|
getClientForAccount: (id: string) => (connected && id === 'other@host' ? { fetchBlob: otherFetch } as never : undefined),
|
|
});
|
|
try {
|
|
const { container } = render(
|
|
<Avatar name="Ann Lee" contactPhoto={{ blobId: 'dbcAnn', mediaType: 'image/jpeg', localAccountId: 'other@host' }} />,
|
|
);
|
|
await act(async () => {});
|
|
// The active client belongs to another login, maybe another server.
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
expect(container.textContent).toBe('AL');
|
|
|
|
// Nothing was remembered as a failure: once connected, the photo comes.
|
|
connected = true;
|
|
act(() => useAuthStore.setState({ client: { fetchBlob } as never }));
|
|
await waitFor(() => expect(img(container)?.getAttribute('src')).toBe('blob:photo'));
|
|
expect(otherFetch).toHaveBeenCalledTimes(1);
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
} finally {
|
|
useAuthStore.setState({ getClientForAccount: original });
|
|
}
|
|
});
|
|
|
|
it('downloads nothing when images are disabled', () => {
|
|
render(<Avatar name="Ann" contactPhoto={{ blobId: 'dbcAnn' }} disableImages />);
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// #960: a refresh must reach past the browser's two-week cache of the old
|
|
// icon and retry domains whose favicon failed before.
|
|
it('requests the sender favicon again after a refresh', () => {
|
|
useFaviconRefreshStore.setState({ refreshedAt: 0 });
|
|
const { container } = render(<Avatar name="Jan" email="jan@example.cz" />);
|
|
const img = () => container.querySelector('img');
|
|
expect(img()?.getAttribute('src')).toBe('/api/favicon?domain=example.cz');
|
|
|
|
fireEvent.error(img()!);
|
|
expect(img()).toBeNull();
|
|
|
|
act(() => useFaviconRefreshStore.getState().refresh());
|
|
const stamp = useFaviconRefreshStore.getState().refreshedAt;
|
|
expect(stamp).toBeGreaterThan(0);
|
|
expect(img()?.getAttribute('src')).toBe(`/api/favicon?domain=example.cz&v=${stamp}`);
|
|
});
|
|
|
|
// #903: no sender icons, and so no /api/favicon request, while third-party traffic is off.
|
|
it('shows initials instead of the sender favicon in the no-third-party mode', () => {
|
|
useFaviconRefreshStore.setState({ refreshedAt: 0 });
|
|
setNoThirdPartyTraffic(true);
|
|
try {
|
|
const { container } = render(<Avatar name="Jan" email="jan@example.cz" />);
|
|
expect(container.querySelector('img')).toBeNull();
|
|
expect(container.textContent).toBe('JA');
|
|
} finally {
|
|
setNoThirdPartyTraffic(false);
|
|
}
|
|
});
|
|
|
|
// #1439: a forged From must not get the real sender's logo or photo.
|
|
describe('sender warning', () => {
|
|
it('shows a shield instead of the favicon and the initials', () => {
|
|
useFaviconRefreshStore.setState({ refreshedAt: 0 });
|
|
const { container } = render(<Avatar name="PayPal" email="billing@paypal.com" senderWarning="Sender check failed" />);
|
|
expect(container.querySelector('img')).toBeNull();
|
|
expect(container.textContent).toBe('');
|
|
expect(container.querySelector('svg')).not.toBeNull();
|
|
expect((container.firstChild as HTMLElement).getAttribute('title')).toBe('PayPal (Sender check failed)');
|
|
});
|
|
|
|
it('downloads no contact photo for the claimed sender', () => {
|
|
const fetchBlob = vi.fn();
|
|
useAuthStore.setState({ client: { fetchBlob } as never });
|
|
const { container } = render(<Avatar name="Ann" email="ann@example.com" contactPhoto={{ blobId: 'dbcAnn' }} senderWarning="Unverified sender" />);
|
|
expect(container.querySelector('img')).toBeNull();
|
|
expect(fetchBlob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows the shield, not the brand logo, when both are given', () => {
|
|
const { container } = render(<Avatar name="PayPal" email="billing@paypal.com" senderWarning="Sender check failed" brandDomain="paypal.com" />);
|
|
expect(container.querySelector('img')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('brand logo (BIMI)', () => {
|
|
beforeEach(() => {
|
|
useFaviconRefreshStore.setState({ refreshedAt: 0 });
|
|
useFaviconRefreshStore.getState().refresh();
|
|
useFaviconRefreshStore.setState({ refreshedAt: 0 });
|
|
});
|
|
|
|
it('shows the logo of a domain the message passed DMARC for, before the favicon', () => {
|
|
const { container } = render(<Avatar name="Brand" email="news@brand.org" brandDomain="brand.org" />);
|
|
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/bimi?domain=brand.org');
|
|
});
|
|
|
|
it('asks for no logo without a DMARC pass', () => {
|
|
const { container } = render(<Avatar name="Brand" email="news@brand.org" />);
|
|
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org');
|
|
});
|
|
|
|
it('asks for no logo when the avatar shows someone from another domain', () => {
|
|
const { container } = render(<Avatar name="Ann" email="ann@other.org" brandDomain="brand.org" />);
|
|
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=other.org');
|
|
});
|
|
|
|
it('falls through to the favicon when the domain has no logo', () => {
|
|
const { container } = render(<Avatar name="Brand" email="news@brand.org" brandDomain="brand.org" />);
|
|
const img = container.querySelector('img')!;
|
|
// /api/bimi answers a 1x1 placeholder when there is no logo.
|
|
Object.defineProperty(img, 'naturalWidth', { value: 1 });
|
|
fireEvent.load(img);
|
|
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org');
|
|
// Other avatars of the domain don't ask again.
|
|
const { container: second } = render(<Avatar name="Brand" email="news@brand.org" brandDomain="brand.org" />);
|
|
expect(second.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org');
|
|
});
|
|
|
|
it('keeps a contact photo first', async () => {
|
|
clearContactPhotoCache();
|
|
const fetchBlob = vi.fn(async () => new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }));
|
|
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:photo');
|
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
useAuthStore.setState({ client: { fetchBlob } as never });
|
|
try {
|
|
const { container } = render(<Avatar name="Ann" email="ann@brand.org" contactPhoto={{ blobId: 'dbcAnnBrand' }} brandDomain="brand.org" />);
|
|
await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:photo'));
|
|
} finally {
|
|
useAuthStore.setState({ client: null });
|
|
clearContactPhotoCache();
|
|
vi.restoreAllMocks();
|
|
}
|
|
});
|
|
|
|
it('asks for no logo while sender icons are off', () => {
|
|
useSettingsStore.setState({ senderFavicons: false });
|
|
try {
|
|
const { container } = render(<Avatar name="Brand" email="news@brand.org" brandDomain="brand.org" />);
|
|
expect(container.querySelector('img')).toBeNull();
|
|
} finally {
|
|
useSettingsStore.setState({ senderFavicons: true });
|
|
}
|
|
});
|
|
});
|
|
});
|