Merge branch 'main' into feature/scheduled-send
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { plainTextToComposerBody } from "../email-composer-utils";
|
||||
|
||||
describe("plainTextToComposerBody", () => {
|
||||
it("returns an empty string for empty input", () => {
|
||||
expect(plainTextToComposerBody("")).toBe("");
|
||||
});
|
||||
|
||||
it("escapes HTML before building composer paragraphs", () => {
|
||||
expect(plainTextToComposerBody("<script>alert('x') & \"q\"</script>")).toBe(
|
||||
"<p><script>alert('x') & "q"</script></p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes line endings and preserves single line breaks", () => {
|
||||
expect(plainTextToComposerBody("line1\r\nline2\rline3")).toBe(
|
||||
"<p>line1<br>line2<br>line3</p>"
|
||||
);
|
||||
});
|
||||
|
||||
it("splits paragraphs on blank lines", () => {
|
||||
expect(plainTextToComposerBody("first\n\nsecond\nthird")).toBe(
|
||||
"<p>first</p><p>second<br>third</p>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
|
||||
expect(clean).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should remove images from signatures', () => {
|
||||
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
|
||||
it('should allow img with https src', () => {
|
||||
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
|
||||
expect(clean).toContain('alt="Logo"');
|
||||
expect(clean).toContain('width="120"');
|
||||
expect(clean).toContain('height="40"');
|
||||
});
|
||||
|
||||
it('should allow img with data:image/png;base64 src', () => {
|
||||
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
|
||||
const signature = `<img src="${dataUri}" alt="Logo">`;
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('data:image/png;base64,');
|
||||
});
|
||||
|
||||
it('should allow img with data:image/jpeg, gif, webp', () => {
|
||||
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
|
||||
for (const src of cases) {
|
||||
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain(src);
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip img with http: src (https only)', () => {
|
||||
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('http://insecure.example.com');
|
||||
expect(clean).not.toContain('<img');
|
||||
expect(clean).toContain('John');
|
||||
});
|
||||
|
||||
it('should strip img with javascript: src', () => {
|
||||
const signature = '<img src="javascript:alert(1)" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('javascript:');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
|
||||
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('data:image/svg');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip img with non-image data: URI', () => {
|
||||
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('data:text/html');
|
||||
expect(clean).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('should strip event handlers on img', () => {
|
||||
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('onerror');
|
||||
expect(clean).not.toContain('onload');
|
||||
expect(clean).toContain('https://cdn.example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should remove video and audio tags', () => {
|
||||
@@ -112,17 +168,31 @@ describe('email-sanitization', () => {
|
||||
expect(sanitizeSignatureHtml(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('should be stricter than email sanitization', () => {
|
||||
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
|
||||
it('should be stricter than email sanitization for script-bearing tags', () => {
|
||||
const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video><iframe src="x"></iframe>';
|
||||
const emailClean = sanitizeEmailHtml(html);
|
||||
const signatureClean = sanitizeSignatureHtml(html);
|
||||
|
||||
// Email allows img and table
|
||||
expect(emailClean).toContain('<img');
|
||||
// Both preserve tables (signatures are universally table-based)
|
||||
expect(emailClean).toContain('<table>');
|
||||
expect(signatureClean).toContain('<table');
|
||||
expect(signatureClean).toContain('Data');
|
||||
|
||||
// Signature blocks img but may allow some tables (verify in implementation)
|
||||
expect(signatureClean).not.toContain('<img');
|
||||
// Signature still blocks media and frames
|
||||
expect(signatureClean).not.toContain('<video');
|
||||
expect(signatureClean).not.toContain('<iframe');
|
||||
expect(signatureClean).toContain('Text');
|
||||
});
|
||||
|
||||
it('should preserve table layout attributes used by email signatures', () => {
|
||||
const signature = '<table cellpadding="0" cellspacing="0" border="0"><tr><td valign="top" align="left" bgcolor="#fafafa" colspan="2">Name</td></tr></table>';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('cellpadding');
|
||||
expect(clean).toContain('cellspacing');
|
||||
expect(clean).toContain('valign');
|
||||
expect(clean).toContain('align');
|
||||
expect(clean).toContain('bgcolor');
|
||||
expect(clean).toContain('colspan');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import {
|
||||
ImpersonationJwtError,
|
||||
verifyImpersonationJwt,
|
||||
impersonationReplayCache,
|
||||
} from '@/lib/impersonation/jwt';
|
||||
|
||||
const SECRET = 'a'.repeat(64);
|
||||
const ISSUER = 'platform-api/webmail';
|
||||
|
||||
function base64Url(input: Buffer | string): string {
|
||||
return Buffer.from(input)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function sign(payload: Record<string, unknown>, secret: string = SECRET, header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' }): string {
|
||||
const h = base64Url(JSON.stringify(header));
|
||||
const p = base64Url(JSON.stringify(payload));
|
||||
const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest();
|
||||
return `${h}.${p}.${base64Url(sig)}`;
|
||||
}
|
||||
|
||||
function basePayload(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
iss: ISSUER,
|
||||
iat: now,
|
||||
exp: now + 120,
|
||||
jti: 'jti-' + Math.random().toString(36).slice(2),
|
||||
mailbox: 'alice@example.test',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('verifyImpersonationJwt', () => {
|
||||
beforeEach(() => {
|
||||
impersonationReplayCache.clear();
|
||||
});
|
||||
|
||||
it('accepts a valid HS256 token', () => {
|
||||
const token = sign(basePayload());
|
||||
const claims = verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER });
|
||||
expect(claims.mailbox).toBe('alice@example.test');
|
||||
});
|
||||
|
||||
it('rejects non-HS256 algorithms', () => {
|
||||
const header = { alg: 'none', typ: 'JWT' };
|
||||
const h = base64Url(JSON.stringify(header));
|
||||
const p = base64Url(JSON.stringify(basePayload()));
|
||||
const token = `${h}.${p}.`;
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrow(ImpersonationJwtError);
|
||||
});
|
||||
|
||||
it('rejects tokens with a forged signature', () => {
|
||||
const token = sign(basePayload(), 'a-different-secret-that-is-also-long-enough-32');
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/signature/i);
|
||||
});
|
||||
|
||||
it('rejects when secret is too short', () => {
|
||||
const token = sign(basePayload());
|
||||
expect(() => verifyImpersonationJwt(token, 'short')).toThrowError(/32 characters/);
|
||||
});
|
||||
|
||||
it('rejects expired tokens', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = sign(basePayload({ iat: now - 600, exp: now - 300 }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/expired/i);
|
||||
});
|
||||
|
||||
it('rejects tokens with lifetime over the 300s ceiling', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = sign(basePayload({ iat: now, exp: now + 3600 }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/lifetime/i);
|
||||
});
|
||||
|
||||
it('rejects tokens with iss mismatch when expectedIssuer is set', () => {
|
||||
const token = sign(basePayload({ iss: 'someone-else' }));
|
||||
expect(() =>
|
||||
verifyImpersonationJwt(token, SECRET, { expectedIssuer: ISSUER }),
|
||||
).toThrowError(/issuer/i);
|
||||
});
|
||||
|
||||
it("rejects mailbox containing '%'", () => {
|
||||
const token = sign(basePayload({ mailbox: 'a%b@example.test' }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/'%'/);
|
||||
});
|
||||
|
||||
it("rejects mailbox containing ':'", () => {
|
||||
const token = sign(basePayload({ mailbox: 'a:b@example.test' }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/':'/);
|
||||
});
|
||||
|
||||
it('rejects malformed tokens', () => {
|
||||
expect(() => verifyImpersonationJwt('not.a.jwt.extra', SECRET)).toThrow();
|
||||
expect(() => verifyImpersonationJwt('', SECRET)).toThrow();
|
||||
});
|
||||
|
||||
it('honours nbf with skew', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = sign(basePayload({ nbf: now + 600 }));
|
||||
expect(() => verifyImpersonationJwt(token, SECRET)).toThrowError(/not yet valid/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('impersonationReplayCache', () => {
|
||||
beforeEach(() => {
|
||||
impersonationReplayCache.clear();
|
||||
});
|
||||
|
||||
it('accepts a jti once and rejects it on second use', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(true);
|
||||
expect(impersonationReplayCache.consume('jti-1', now + 60, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('prunes expired jtis on next consume', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
|
||||
// Far in the future — pruning should clear the old entry.
|
||||
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
|
||||
// Re-using the old jti is allowed after pruning (security irrelevant since
|
||||
// the token would fail signature/exp validation upstream).
|
||||
expect(impersonationReplayCache.consume('jti-old', now + 60, now + 1000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,25 @@ describe('JMAPClient.sendEmail threading headers', () => {
|
||||
expect(draft.references).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits cc/bcc when arrays are empty so the server does not emit a bare Cc: header', async () => {
|
||||
const client = createClient();
|
||||
const captured = mockSendEmailFlow();
|
||||
|
||||
await client.sendEmail(
|
||||
['recipient@example.com'],
|
||||
'No copies',
|
||||
'body',
|
||||
[], [], 'identity-1', 'user@example.com',
|
||||
);
|
||||
|
||||
const setCall = captured[2].methodCalls[0];
|
||||
const create = setCall[1].create as Record<string, Record<string, unknown>>;
|
||||
const draft = Object.values(create)[0];
|
||||
|
||||
expect(draft.cc).toBeUndefined();
|
||||
expect(draft.bcc).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
|
||||
const client = createClient();
|
||||
const captured = mockSendEmailFlow();
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { OAuthMetadata } from '../oauth/discovery';
|
||||
|
||||
const validateEndpoint = async (urlString: string) => {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (url.username || url.password) return false;
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) return false;
|
||||
if (/^(127\.|169\.254\.|10\.|192\.168\.)/.test(host)) return false;
|
||||
if (host === '::1' || host === '0.0.0.0') return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const VALID_METADATA: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
@@ -26,7 +41,7 @@ describe('oauth/discovery', () => {
|
||||
json: () => Promise.resolve(VALID_METADATA),
|
||||
}));
|
||||
|
||||
const result = await discoverOAuth('https://mail.example.com');
|
||||
const result = await discoverOAuth('https://mail.example.com', { validateEndpoint });
|
||||
|
||||
expect(result).toEqual(VALID_METADATA);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
@@ -43,7 +58,7 @@ describe('oauth/discovery', () => {
|
||||
json: () => Promise.resolve(VALID_METADATA),
|
||||
}));
|
||||
|
||||
const result = await discoverOAuth('https://fallback.example.com');
|
||||
const result = await discoverOAuth('https://fallback.example.com', { validateEndpoint });
|
||||
|
||||
expect(result).toEqual(VALID_METADATA);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
@@ -59,7 +74,7 @@ describe('oauth/discovery', () => {
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 }));
|
||||
|
||||
const result = await discoverOAuth('https://fail.example.com');
|
||||
const result = await discoverOAuth('https://fail.example.com', { validateEndpoint });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
@@ -71,7 +86,7 @@ describe('oauth/discovery', () => {
|
||||
json: () => Promise.resolve(VALID_METADATA),
|
||||
}));
|
||||
|
||||
const result = await discoverOAuth('https://optional.example.com');
|
||||
const result = await discoverOAuth('https://optional.example.com', { validateEndpoint });
|
||||
|
||||
expect(result?.revocation_endpoint).toBe('https://auth.example.com/revoke');
|
||||
expect(result?.end_session_endpoint).toBe('https://auth.example.com/logout');
|
||||
@@ -86,7 +101,46 @@ describe('oauth/discovery', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 }));
|
||||
|
||||
const result = await discoverOAuth('https://incomplete.example.com');
|
||||
const result = await discoverOAuth('https://incomplete.example.com', { validateEndpoint });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects metadata pointing at loopback / link-local hosts (SSRF guard)', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
issuer: 'https://evil.example.com',
|
||||
authorization_endpoint: 'https://evil.example.com/authorize',
|
||||
token_endpoint: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 }));
|
||||
|
||||
const result = await discoverOAuth('https://evil.example.com', { validateEndpoint });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects metadata pointing at private RFC1918 hosts (SSRF guard)', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
issuer: 'https://evil.example.com',
|
||||
authorization_endpoint: 'https://evil.example.com/authorize',
|
||||
token_endpoint: 'https://evil.example.com/token',
|
||||
revocation_endpoint: 'http://127.0.0.1:9200/_cluster/state',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 }));
|
||||
|
||||
const result = await discoverOAuth('https://private-revoke.example.com', { validateEndpoint });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
@@ -98,8 +152,8 @@ describe('oauth/discovery', () => {
|
||||
json: () => Promise.resolve(VALID_METADATA),
|
||||
}));
|
||||
|
||||
const first = await discoverOAuth('https://cached.example.com');
|
||||
const second = await discoverOAuth('https://cached.example.com');
|
||||
const first = await discoverOAuth('https://cached.example.com', { validateEndpoint });
|
||||
const second = await discoverOAuth('https://cached.example.com', { validateEndpoint });
|
||||
|
||||
expect(first).toEqual(VALID_METADATA);
|
||||
expect(second).toEqual(VALID_METADATA);
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { clearAllHooks } from '../plugin-hooks';
|
||||
|
||||
function makePlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
return {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
author: 'Test',
|
||||
description: '',
|
||||
type: 'ui-extension',
|
||||
entrypoint: 'index.js',
|
||||
permissions: [],
|
||||
enabled: true,
|
||||
status: 'running',
|
||||
settings: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
localStorage.clear();
|
||||
setSlotRegistrationBridge(null);
|
||||
});
|
||||
|
||||
describe('createPluginAPI', () => {
|
||||
it('exposes plugin info', () => {
|
||||
const plugin = makePlugin();
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(api.plugin.id).toBe('test-plugin');
|
||||
expect(api.plugin.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('returns a frozen copy of settings', () => {
|
||||
const plugin = makePlugin({ settings: { key: 'val' } });
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(api.plugin.settings).toEqual({ key: 'val' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin storage (scoped localStorage)', () => {
|
||||
it('set and get a value', () => {
|
||||
const api = createPluginAPI(makePlugin());
|
||||
api.storage.set('foo', 42);
|
||||
expect(api.storage.get('foo')).toBe(42);
|
||||
});
|
||||
|
||||
it('scopes to plugin id', () => {
|
||||
const api1 = createPluginAPI(makePlugin({ id: 'p1' }));
|
||||
const api2 = createPluginAPI(makePlugin({ id: 'p2' }));
|
||||
api1.storage.set('key', 'a');
|
||||
api2.storage.set('key', 'b');
|
||||
expect(api1.storage.get('key')).toBe('a');
|
||||
expect(api2.storage.get('key')).toBe('b');
|
||||
});
|
||||
|
||||
it('remove deletes a value', () => {
|
||||
const api = createPluginAPI(makePlugin());
|
||||
api.storage.set('x', 10);
|
||||
api.storage.remove('x');
|
||||
expect(api.storage.get('x')).toBeNull();
|
||||
});
|
||||
|
||||
it('keys lists only plugin-scoped keys', () => {
|
||||
const api = createPluginAPI(makePlugin({ id: 'kp' }));
|
||||
api.storage.set('a', 1);
|
||||
api.storage.set('b', 2);
|
||||
localStorage.setItem('unrelated', 'val');
|
||||
expect(api.storage.keys()).toContain('a');
|
||||
expect(api.storage.keys()).toContain('b');
|
||||
expect(api.storage.keys()).not.toContain('unrelated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin logger', () => {
|
||||
it('prefixes log messages with plugin id', () => {
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const api = createPluginAPI(makePlugin({ id: 'log-test' }));
|
||||
api.log.info('hello');
|
||||
expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello');
|
||||
infoSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks permission gating', () => {
|
||||
it('returns no-op disposable without permission', () => {
|
||||
const plugin = makePlugin({ permissions: [] }); // no email:read
|
||||
const api = createPluginAPI(plugin);
|
||||
const d = api.hooks.onEmailOpen(vi.fn());
|
||||
expect(d).toBeDefined();
|
||||
expect(d.dispose).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('registers handler when permission is granted', () => {
|
||||
const plugin = makePlugin({ permissions: ['email:read'] });
|
||||
const api = createPluginAPI(plugin);
|
||||
const fn = vi.fn();
|
||||
const d = api.hooks.onEmailOpen(fn);
|
||||
expect(d).toBeDefined();
|
||||
d.dispose(); // should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui permission requirement', () => {
|
||||
it('throws without ui:toolbar permission', () => {
|
||||
const plugin = makePlugin({ permissions: [] });
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(() => api.ui.registerToolbarAction({
|
||||
id: 'test',
|
||||
label: 'Test',
|
||||
onClick: () => {},
|
||||
})).toThrow('lacks permission');
|
||||
});
|
||||
|
||||
it('does not throw with correct permission (slot bridge not set, returns no-op)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const plugin = makePlugin({ permissions: ['ui:toolbar'] });
|
||||
const api = createPluginAPI(plugin);
|
||||
const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} });
|
||||
expect(d.dispose).toBeInstanceOf(Function);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slot registration bridge', () => {
|
||||
it('calls bridge when set', () => {
|
||||
const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} }));
|
||||
setSlotRegistrationBridge(bridge);
|
||||
|
||||
const plugin = makePlugin({ permissions: ['ui:email-footer'] });
|
||||
const api = createPluginAPI(plugin);
|
||||
const DummyComponent = () => null;
|
||||
api.ui.registerEmailFooter(DummyComponent);
|
||||
expect(bridge).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast bridge', () => {
|
||||
it('exposes success/error/info/warning methods', () => {
|
||||
const plugin = makePlugin();
|
||||
const api = createPluginAPI(plugin);
|
||||
expect(api.toast.success).toBeInstanceOf(Function);
|
||||
expect(api.toast.error).toBeInstanceOf(Function);
|
||||
expect(api.toast.info).toBeInstanceOf(Function);
|
||||
expect(api.toast.warning).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('http.post path validation', () => {
|
||||
function makeApi(permissions: string[] = ['http:post']) {
|
||||
return createPluginAPI(makePlugin({ permissions }));
|
||||
}
|
||||
|
||||
it('rejects protocol-relative URLs like //evil.example', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects absolute URLs to other origins', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths not under /api/', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/');
|
||||
});
|
||||
|
||||
it('rejects paths that use backslash to bypass the check', async () => {
|
||||
const api = makeApi();
|
||||
await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws without http:post permission', async () => {
|
||||
const api = makeApi([]);
|
||||
await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission');
|
||||
});
|
||||
|
||||
it('accepts a valid /api/ path', async () => {
|
||||
const api = makeApi();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ url: 'https://meet.example.com/room' }),
|
||||
});
|
||||
const result = await api.http.post('/api/jitsi', { eventTitle: 'test' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual({ url: 'https://meet.example.com/room' });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// We test the raw store by directly invoking Zustand
|
||||
// Mock the external dependencies the store imports
|
||||
@@ -28,10 +28,6 @@ vi.mock('@/lib/plugin-loader', () => ({
|
||||
setupAutoDisable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-api', () => ({
|
||||
setSlotRegistrationBridge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
removeAllPluginHooks: vi.fn(),
|
||||
}));
|
||||
@@ -42,21 +38,6 @@ import { usePluginStore } from '@/stores/plugin-store';
|
||||
function resetStore() {
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
slots: {
|
||||
'toolbar-actions': [],
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'composer-sidebar': [],
|
||||
'composer-sidebar-right': [],
|
||||
'sidebar-widget': [],
|
||||
'email-detail-sidebar': [],
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
'calendar-event-actions': [],
|
||||
'admin-plugin-page': [],
|
||||
},
|
||||
initialized: false,
|
||||
});
|
||||
}
|
||||
@@ -84,31 +65,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('usePluginStore', () => {
|
||||
describe('registerSlot / dispose', () => {
|
||||
it('adds registration to slot and removes on dispose', () => {
|
||||
const { registerSlot } = usePluginStore.getState();
|
||||
const reg: SlotRegistration = {
|
||||
pluginId: 'p1',
|
||||
component: () => null,
|
||||
order: 100,
|
||||
};
|
||||
const disposable = registerSlot('toolbar-actions', reg);
|
||||
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1);
|
||||
disposable.dispose();
|
||||
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sorts registrations by order', () => {
|
||||
const { registerSlot } = usePluginStore.getState();
|
||||
registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 });
|
||||
registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 });
|
||||
registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 });
|
||||
|
||||
const regs = usePluginStore.getState().slots['email-banner'];
|
||||
expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPluginStatus', () => {
|
||||
it('updates status for existing plugin', () => {
|
||||
usePluginStore.setState({ plugins: [mockPlugin()] });
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseMailto } from "../protocol-handlers/mailto";
|
||||
import { listenForMailtoRequests } from "../protocol-handlers/session";
|
||||
import { parseWebcal } from "../protocol-handlers/webcal";
|
||||
|
||||
const originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, "serviceWorker");
|
||||
|
||||
function installServiceWorkerMock() {
|
||||
const listeners = new Set<(event: MessageEvent) => void>();
|
||||
const worker = { postMessage: vi.fn() };
|
||||
const serviceWorker = {
|
||||
ready: Promise.resolve({ active: worker }),
|
||||
controller: worker,
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.add(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === "message") listeners.delete(listener as (event: MessageEvent) => void);
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: serviceWorker,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatch(data: unknown) {
|
||||
listeners.forEach((listener) => listener(new MessageEvent("message", { data })));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalServiceWorkerDescriptor) {
|
||||
Object.defineProperty(navigator, "serviceWorker", originalServiceWorkerDescriptor);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
});
|
||||
|
||||
describe("protocol handlers", () => {
|
||||
describe("parseMailto", () => {
|
||||
it("parses a single path recipient", () => {
|
||||
expect(parseMailto("mailto:alice@example.com")).toEqual({
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses multiple recipients with subject and body", () => {
|
||||
expect(parseMailto("mailto:alice@example.com,bob@example.com?subject=Hello&body=Hi")).toMatchObject({
|
||||
to: ["alice@example.com", "bob@example.com"],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses to, cc, and bcc query recipients", () => {
|
||||
expect(parseMailto("mailto:?to=alice@example.com&cc=bob@example.com&bcc=eve@example.com")).toMatchObject({
|
||||
to: ["alice@example.com"],
|
||||
cc: ["bob@example.com"],
|
||||
bcc: ["eve@example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes subject and body values", () => {
|
||||
expect(parseMailto("mailto:alice@example.com?subject=Hello%20World&body=line1%0Aline2")).toMatchObject({
|
||||
subject: "Hello World",
|
||||
body: "line1\nline2",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal plus signs in query values", () => {
|
||||
expect(parseMailto("mailto:?to=user+tag@example.com&subject=C++&body=a+b")).toMatchObject({
|
||||
to: ["user+tag@example.com"],
|
||||
subject: "C++",
|
||||
body: "a+b",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-mailto URLs", () => {
|
||||
expect(parseMailto("https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows an empty mailto URL", () => {
|
||||
expect(parseMailto("mailto:")).toEqual({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes control characters and caps recipients", () => {
|
||||
const recipients = Array.from({ length: 250 }, (_, index) => `user${index}@example.com`).join(",");
|
||||
const parsed = parseMailto(`mailto:${recipients}?subject=Hi%0ABcc:evil@example.com`);
|
||||
expect(parsed?.to).toHaveLength(200);
|
||||
expect(parsed?.subject).toBe("HiBcc:evil@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWebcal", () => {
|
||||
it("normalizes webcal to https", () => {
|
||||
expect(parseWebcal("webcal://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("normalizes webcals to https", () => {
|
||||
expect(parseWebcal("webcals://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(parseWebcal("https://example.com/calendar.ics")?.subscriptionUrl).toBe("https://example.com/calendar.ics");
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(parseWebcal("ftp://example.com/calendar.ics")).toBeNull();
|
||||
});
|
||||
|
||||
it("suggests a name from the path", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics")?.suggestedName).toBe("team");
|
||||
});
|
||||
|
||||
it("falls back to hostname for suggested name", () => {
|
||||
expect(parseWebcal("webcal://example.com/")?.suggestedName).toBe("example.com");
|
||||
});
|
||||
|
||||
it("prefers a name query parameter", () => {
|
||||
expect(parseWebcal("webcal://example.com/team.ics?name=Team%20Calendar")?.suggestedName).toBe("Team Calendar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenForMailtoRequests", () => {
|
||||
const mailtoValue = {
|
||||
to: ["alice@example.com"],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: "Hello",
|
||||
body: "Hi",
|
||||
};
|
||||
|
||||
it("accepts legacy service-worker mailto messages without a client id", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
vi.spyOn(window, "focus").mockImplementation(() => undefined);
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "legacy", value: mailtoValue });
|
||||
|
||||
expect(onMailto).toHaveBeenCalledWith(mailtoValue);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("ignores service-worker mailto messages for another client", () => {
|
||||
const serviceWorker = installServiceWorkerMock();
|
||||
const onMailto = vi.fn();
|
||||
|
||||
const cleanup = listenForMailtoRequests(onMailto, () => ({ path: "/", standalone: false }));
|
||||
serviceWorker.dispatch({ type: "mailto-request", id: "targeted", clientId: "other-client", value: mailtoValue });
|
||||
|
||||
expect(onMailto).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findReplyIdentityId } from '../reply-identity';
|
||||
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
|
||||
import type { Identity } from '../jmap/types';
|
||||
|
||||
const identities: Identity[] = [
|
||||
@@ -49,4 +49,39 @@ describe('findReplyIdentityId', () => {
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveReplyFrom', () => {
|
||||
it('returns the matching identity with no override when exact match', () => {
|
||||
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
|
||||
.toEqual({ identityId: 'secondary' });
|
||||
});
|
||||
|
||||
it('strips +tag before matching identities', () => {
|
||||
expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }))
|
||||
.toEqual({ identityId: 'primary' });
|
||||
});
|
||||
|
||||
it('surfaces catch-all override when recipient is on an identity domain but not an identity', () => {
|
||||
const result = resolveReplyFrom(identities, {
|
||||
to: [{ email: 'stripe@primary.com', name: 'Stripe' }],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
identityId: 'primary',
|
||||
overrideEmail: 'stripe@primary.com',
|
||||
overrideName: 'Stripe',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers identity match over catch-all override when both appear', () => {
|
||||
const result = resolveReplyFrom(identities, {
|
||||
to: [{ email: 'harry@primary.com' }, { email: 'stripe@primary.com' }],
|
||||
});
|
||||
expect(result).toEqual({ identityId: 'primary' });
|
||||
});
|
||||
|
||||
it('returns null when recipients are on foreign domains', () => {
|
||||
expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] }))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -481,6 +481,258 @@ describe("round-trip: parse → generate → parse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("vCard 4.0 parsing (issue #289)", () => {
|
||||
it("strips group prefix from property names (item1.EMAIL)", () => {
|
||||
// Evolution / Apple Contacts emit grouped properties so an X-ABLABEL line
|
||||
// can attach a label. We must still parse the EMAIL itself.
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Ada Lovelace",
|
||||
"item1.EMAIL:ada@example.com",
|
||||
"item1.X-ABLABEL:Personal",
|
||||
"item2.TEL:tel:+1-555-0100",
|
||||
"item2.X-ABLABEL:Mobile",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
const card = result[0];
|
||||
expect(card.emails?.e0?.address).toBe("ada@example.com");
|
||||
expect(card.phones?.p0?.number).toBe("+1-555-0100");
|
||||
});
|
||||
|
||||
it("strips tel:/mailto: URI scheme from TEL/EMAIL values", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Alan Turing",
|
||||
"EMAIL:mailto:alan@example.com",
|
||||
"TEL;VALUE=uri:tel:+44-20-1234-5678",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result[0].emails?.e0?.address).toBe("alan@example.com");
|
||||
expect(result[0].phones?.p0?.number).toBe("+44-20-1234-5678");
|
||||
});
|
||||
|
||||
it("maps PREF=n parameter to pref field", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Grace Hopper",
|
||||
"EMAIL;PREF=1:grace@home.example",
|
||||
"EMAIL;PREF=2:grace@work.example",
|
||||
"TEL;PREF=1:+1-555-9999",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result[0].emails?.e0?.pref).toBe(1);
|
||||
expect(result[0].emails?.e1?.pref).toBe(2);
|
||||
expect(result[0].phones?.p0?.pref).toBe(1);
|
||||
});
|
||||
|
||||
it("decodes RFC 6868 caret-encoded parameter values", () => {
|
||||
// ^n → LF, ^^ → ^, ^' → DQUOTE
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Test",
|
||||
'ADR;LABEL="Line 1^nLine 2";TYPE=HOME:;;Sub St;Town;;;US',
|
||||
"EMAIL:t@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result[0].addresses?.a0?.fullAddress).toBe("Line 1\nLine 2");
|
||||
expect(result[0].addresses?.a0?.contexts).toEqual({ private: true });
|
||||
});
|
||||
|
||||
it("survives quoted parameter values containing semicolons", () => {
|
||||
// Without quote-aware param splitting, the ; inside LABEL would shred
|
||||
// the param list and the ADR would lose its TYPE.
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Lev",
|
||||
'ADR;LABEL="Building A; Suite 12";TYPE=WORK:;;1 Plaza;NYC;NY;10001;US',
|
||||
"EMAIL:lev@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result[0].addresses?.a0?.fullAddress).toBe("Building A; Suite 12");
|
||||
expect(result[0].addresses?.a0?.contexts).toEqual({ work: true });
|
||||
expect(result[0].addresses?.a0?.locality).toBe("NYC");
|
||||
});
|
||||
|
||||
it("parses BIRTHPLACE and DEATHPLACE (RFC 6474)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Marie Curie",
|
||||
"BDAY:18671107",
|
||||
"BIRTHPLACE:Warsaw\\, Poland",
|
||||
"DEATHDATE:19340704",
|
||||
"DEATHPLACE:Passy\\, France",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
const annivs = Object.values(result[0].anniversaries || {});
|
||||
const birth = annivs.find((a) => a.kind === "birth");
|
||||
const death = annivs.find((a) => a.kind === "death");
|
||||
expect(birth?.place?.fullAddress).toBe("Warsaw, Poland");
|
||||
expect(death?.place?.fullAddress).toBe("Passy, France");
|
||||
});
|
||||
|
||||
it("parses EXPERTISE / HOBBY / INTEREST with LEVEL (RFC 6715)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Polymath",
|
||||
"EXPERTISE;LEVEL=expert:cryptography",
|
||||
"EXPERTISE;LEVEL=beginner:welding",
|
||||
"HOBBY;LEVEL=high:gardening",
|
||||
"INTEREST;LEVEL=medium:opera",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
const info = Object.values(result[0].personalInfo || {});
|
||||
expect(info).toEqual(expect.arrayContaining([
|
||||
{ kind: "expertise", value: "cryptography", level: "high" },
|
||||
{ kind: "expertise", value: "welding", level: "low" },
|
||||
{ kind: "hobby", value: "gardening", level: "high" },
|
||||
{ kind: "interest", value: "opera", level: "medium" },
|
||||
]));
|
||||
});
|
||||
|
||||
it("parses ORG-DIRECTORY (RFC 6715) and CONTACT-URI (RFC 8605)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Corp Person",
|
||||
"ORG-DIRECTORY:https://example.com/staff/",
|
||||
"CONTACT-URI;PREF=1:https://example.com/contact",
|
||||
"EMAIL:c@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(Object.values(result[0].directories || {})[0]).toMatchObject({
|
||||
uri: "https://example.com/staff/",
|
||||
kind: "directory",
|
||||
});
|
||||
const links = Object.values(result[0].links || {});
|
||||
expect(links[0]).toMatchObject({
|
||||
uri: "https://example.com/contact",
|
||||
kind: "contact",
|
||||
pref: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses RFC 9554 CREATED, GRAMGENDER, PRONOUNS", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Modern Person",
|
||||
"CREATED:20250101T120000Z",
|
||||
"GRAMGENDER:neuter",
|
||||
"PRONOUNS:they/them",
|
||||
"PRONOUNS;PREF=2:ze/zir",
|
||||
"EMAIL:m@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result[0].created).toBe("20250101T120000Z");
|
||||
expect(result[0].speakToAs?.grammaticalGender).toBe("neuter");
|
||||
const pronouns = Object.values(result[0].speakToAs?.pronouns || {});
|
||||
expect(pronouns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ pronouns: "they/them" }),
|
||||
expect.objectContaining({ pronouns: "ze/zir", pref: 2 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("accepts vCard 4.0 KIND values (location, device, application)", () => {
|
||||
for (const k of ["location", "device", "application"] as const) {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
`KIND:${k}`,
|
||||
"FN:Thing",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
expect(parseVCard(vcf)[0].kind).toBe(k);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles ADR with LABEL/GEO/TZ/CC parameters (RFC 9554)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:GeoPerson",
|
||||
'ADR;CC=DE;GEO="geo:52.5,13.4";TZ=Europe/Berlin;LABEL="Unter den Linden 1\\nBerlin":;;Unter den Linden 1;Berlin;;10117;Germany',
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
const addr = result[0].addresses?.a0;
|
||||
expect(addr?.countryCode).toBe("DE");
|
||||
expect(addr?.coordinates).toBe("52.5,13.4");
|
||||
expect(addr?.timeZone).toBe("Europe/Berlin");
|
||||
expect(addr?.fullAddress).toContain("Unter den Linden 1");
|
||||
expect(addr?.locality).toBe("Berlin");
|
||||
});
|
||||
|
||||
it("unfolds LF-only continuation lines (no CR)", () => {
|
||||
// Unix exporters often use LF only; we must still unfold.
|
||||
const vcf = "BEGIN:VCARD\nVERSION:4.0\nFN:John\n Doe\nEMAIL:j@d.com\nEND:VCARD";
|
||||
const result = parseVCard(vcf);
|
||||
expect(result[0].name?.components).toEqual(
|
||||
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips vCard 4.0-only properties through generateVCard", () => {
|
||||
const original = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"FN:Round Trip",
|
||||
"EMAIL;PREF=1:rt@example.com",
|
||||
"BDAY:19700101",
|
||||
"BIRTHPLACE:Somewhere",
|
||||
"EXPERTISE;LEVEL=expert:vCard",
|
||||
"HOBBY;LEVEL=medium:reading",
|
||||
"ORG-DIRECTORY:https://example.com/dir",
|
||||
"CONTACT-URI:https://example.com/contact",
|
||||
"CREATED:20240101T000000Z",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const exported = generateVCard(parseVCard(original));
|
||||
const reparsed = parseVCard(exported)[0];
|
||||
|
||||
expect(reparsed.emails?.e0?.pref).toBe(1);
|
||||
expect(Object.values(reparsed.anniversaries || {}).find(a => a.kind === "birth")?.place?.fullAddress).toBe("Somewhere");
|
||||
const info = Object.values(reparsed.personalInfo || {});
|
||||
expect(info).toEqual(expect.arrayContaining([
|
||||
{ kind: "expertise", value: "vCard", level: "high" },
|
||||
{ kind: "hobby", value: "reading", level: "medium" },
|
||||
]));
|
||||
expect(Object.values(reparsed.directories || {})[0]?.uri).toBe("https://example.com/dir");
|
||||
expect(Object.values(reparsed.links || {})[0]).toMatchObject({
|
||||
uri: "https://example.com/contact",
|
||||
kind: "contact",
|
||||
});
|
||||
expect(reparsed.created).toBe("20240101T000000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDuplicates", () => {
|
||||
it("detects duplicates by matching email (case-insensitive)", () => {
|
||||
const existing: ContactCard[] = [
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
describe('compareVersions', () => {
|
||||
it('orders by major, minor, patch', () => {
|
||||
expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
|
||||
expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0);
|
||||
expect(compareVersions('1.0.0', '1.0.1')).toBeLessThan(0);
|
||||
expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0);
|
||||
expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('treats missing segments as 0', () => {
|
||||
expect(compareVersions('1', '1.0.0')).toBe(0);
|
||||
expect(compareVersions('1.2', '1.2.0')).toBe(0);
|
||||
});
|
||||
|
||||
it('tolerates a leading v', () => {
|
||||
expect(compareVersions('v1.6.7', '1.6.7')).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores pre-release / build metadata', () => {
|
||||
expect(compareVersions('1.6.7-rc.1', '1.6.7')).toBe(0);
|
||||
expect(compareVersions('1.6.7+build.5', '1.6.7')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVersionSatisfied', () => {
|
||||
it('returns true when current >= required', () => {
|
||||
expect(isVersionSatisfied('1.6.7', '1.6.7')).toBe(true);
|
||||
expect(isVersionSatisfied('1.6.8', '1.6.7')).toBe(true);
|
||||
expect(isVersionSatisfied('2.0.0', '1.9.9')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when current < required', () => {
|
||||
expect(isVersionSatisfied('1.6.6', '1.6.7')).toBe(false);
|
||||
expect(isVersionSatisfied('1.5.0', '1.6.0')).toBe(false);
|
||||
expect(isVersionSatisfied('0.0.0', '1.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty / null / undefined required as no requirement', () => {
|
||||
expect(isVersionSatisfied('1.0.0', '')).toBe(true);
|
||||
expect(isVersionSatisfied('1.0.0', null)).toBe(true);
|
||||
expect(isVersionSatisfied('1.0.0', undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user