584 lines
19 KiB
TypeScript
584 lines
19 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
checkCdn,
|
|
checkAuthSecret,
|
|
checkProxyHops,
|
|
countForwardedHops,
|
|
checkAlbumIds,
|
|
checkAlbumSlugCollisions,
|
|
checkAlbumsShared,
|
|
checkPasswords,
|
|
checkWritable,
|
|
checkImmichCalls,
|
|
checkContact,
|
|
checkLegal,
|
|
checkPrivacy,
|
|
checkContentPages,
|
|
checkSettingValues,
|
|
checkDownloadMetadata,
|
|
checkZoomRenditions,
|
|
worstLevel,
|
|
} from '../admin/doctor';
|
|
|
|
/** A stand-in for lib/config/schema.ts's slugify(), good enough to test the
|
|
* grouping logic without doctor.ts importing anything (see its own comment). */
|
|
const testSlug = (name: string) =>
|
|
name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
describe('checkAuthSecret', () => {
|
|
it('accepts a long secret', () => {
|
|
expect(checkAuthSecret('a'.repeat(64)).level).toBe('ok');
|
|
});
|
|
|
|
it('warns about a short one', () => {
|
|
const f = checkAuthSecret('short');
|
|
expect(f.level).toBe('warn');
|
|
expect(f.title).toContain('5 characters');
|
|
});
|
|
|
|
it('errors when absent', () => {
|
|
expect(checkAuthSecret(undefined).level).toBe('error');
|
|
});
|
|
|
|
/** The report is shown in a browser and pasted into issues. */
|
|
it('never repeats the secret itself', () => {
|
|
const secret = 'super-secret-value-nobody-should-see';
|
|
const f = checkAuthSecret(secret);
|
|
expect(JSON.stringify(f)).not.toContain(secret);
|
|
});
|
|
});
|
|
|
|
describe('countForwardedHops', () => {
|
|
it('counts the entries of a chain', () => {
|
|
expect(countForwardedHops('203.0.113.1, 10.0.0.1')).toBe(2);
|
|
expect(countForwardedHops('203.0.113.1')).toBe(1);
|
|
});
|
|
|
|
it('treats an absent or empty header as no proxy', () => {
|
|
expect(countForwardedHops(null)).toBe(0);
|
|
expect(countForwardedHops('')).toBe(0);
|
|
expect(countForwardedHops(' , ')).toBe(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Both directions fail silently today: too high and the lookup runs off the end
|
|
* of the chain, too low and the IP comes from a header the client writes.
|
|
*/
|
|
describe('checkProxyHops', () => {
|
|
it('is happy when the value matches what arrived', () => {
|
|
expect(checkProxyHops(1, 1, true).level).toBe('ok');
|
|
expect(checkProxyHops(0, 0, false).level).toBe('ok');
|
|
});
|
|
|
|
it('warns when a chain arrived but no hops are configured', () => {
|
|
const f = checkProxyHops(0, 2, true);
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('spoofing');
|
|
});
|
|
|
|
it('warns when more hops are configured than arrived', () => {
|
|
expect(checkProxyHops(3, 2, true).level).toBe('warn');
|
|
});
|
|
|
|
/** An admin on the LAN legitimately sees no chain — say so, do not accuse. */
|
|
it('explains the direct-admin case instead of calling it broken', () => {
|
|
const f = checkProxyHops(1, 0, false);
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('directly');
|
|
});
|
|
|
|
/**
|
|
* Next fills X-Forwarded-For with the socket address when a request arrives
|
|
* without one, so a lone entry is what a *direct* request looks like. Warning
|
|
* on that would fire on every deployment that has no proxy at all.
|
|
*/
|
|
it('does not mistake the header Next writes itself for a proxy', () => {
|
|
const f = checkProxyHops(0, 1, false);
|
|
expect(f.level).toBe('ok');
|
|
expect(f.title).toContain('No reverse proxy');
|
|
});
|
|
|
|
it('does trust a lone entry when a real proxy header backs it up', () => {
|
|
const f = checkProxyHops(0, 1, true);
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('spoofing');
|
|
});
|
|
|
|
it('accepts one configured hop against one corroborated entry', () => {
|
|
expect(checkProxyHops(1, 1, true).level).toBe('ok');
|
|
});
|
|
|
|
/**
|
|
* Cloudflare (1 hop) in front of nginx (1 more, 2 total) with the value set
|
|
* to 1 used to report "matches the observed chain" — the client IP was
|
|
* actually read from the Cloudflare edge address, so every visitor behind
|
|
* that PoP shared one rate-limit bucket (#629).
|
|
*/
|
|
it('warns, not passes, when fewer hops are configured than arrived', () => {
|
|
const f = checkProxyHops(1, 2, true);
|
|
expect(f.level).toBe('warn');
|
|
expect(f.title).toContain('1');
|
|
expect(f.title).toContain('2');
|
|
});
|
|
});
|
|
|
|
describe('checkAlbumIds', () => {
|
|
const known = [
|
|
{ id: 'a', albumName: 'Japan' },
|
|
{ id: 'b', albumName: 'Poland' },
|
|
];
|
|
|
|
it('passes when every configured album resolves', () => {
|
|
expect(checkAlbumIds(['a', 'b'], known).level).toBe('ok');
|
|
});
|
|
|
|
it('names the ids Immich does not know', () => {
|
|
const f = checkAlbumIds(['a', 'ghost'], known);
|
|
expect(f.level).toBe('error');
|
|
expect(f.detail).toContain('ghost');
|
|
expect(f.albumIds).toEqual(['ghost']);
|
|
});
|
|
|
|
it('flags an empty gallery', () => {
|
|
expect(checkAlbumIds([], known).level).toBe('warn');
|
|
});
|
|
});
|
|
|
|
/** Never an error: publishing an unshared album has always worked (#515). */
|
|
describe('checkAlbumsShared', () => {
|
|
const known = [
|
|
{ id: 'a', albumName: 'Japan', shared: true },
|
|
{ id: 'b', albumName: 'Private trip', shared: false },
|
|
];
|
|
|
|
it('is quiet when everything published is shared', () => {
|
|
expect(checkAlbumsShared(['a'], known).level).toBe('ok');
|
|
});
|
|
|
|
it('warns and names the unshared album', () => {
|
|
const f = checkAlbumsShared(['a', 'b'], known);
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('Private trip');
|
|
expect(f.albumIds).toEqual(['b']);
|
|
});
|
|
|
|
/** An older Immich may not report the flag; silence beats a false alarm. */
|
|
it('says nothing when the flag is absent', () => {
|
|
expect(checkAlbumsShared(['x'], [{ id: 'x', albumName: 'Unknown' }]).level).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('checkAlbumSlugCollisions', () => {
|
|
const known = [
|
|
{ id: 'a', albumName: 'Japan Trip' },
|
|
{ id: 'b', albumName: 'Japan-Trip' },
|
|
{ id: 'c', albumName: 'Iceland' },
|
|
];
|
|
|
|
it('is quiet when nothing collides', () => {
|
|
const f = checkAlbumSlugCollisions(
|
|
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
|
|
{},
|
|
known,
|
|
testSlug,
|
|
);
|
|
expect(f.level).toBe('ok');
|
|
});
|
|
|
|
it('flags two albums in the same group whose Immich names slugify alike', () => {
|
|
const f = checkAlbumSlugCollisions(
|
|
[{ context: 'gallery.yaml albums', albumIds: ['a', 'b'] }],
|
|
{},
|
|
known,
|
|
testSlug,
|
|
);
|
|
expect(f.level).toBe('error');
|
|
expect(f.detail).toContain('Japan Trip');
|
|
expect(f.detail).toContain('Japan-Trip');
|
|
expect(f.albumIds).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('does not flag the same collision across two different groups', () => {
|
|
// /trips-a/japan-trip and /trips-b/japan-trip are different URLs.
|
|
const f = checkAlbumSlugCollisions(
|
|
[
|
|
{ context: 'subpage "Trips A"', albumIds: ['a'] },
|
|
{ context: 'subpage "Trips B"', albumIds: ['b'] },
|
|
],
|
|
{},
|
|
known,
|
|
testSlug,
|
|
);
|
|
expect(f.level).toBe('ok');
|
|
});
|
|
|
|
it('prefers a title override over the Immich name', () => {
|
|
const f = checkAlbumSlugCollisions(
|
|
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
|
|
{ c: 'Japan Trip' }, // overrides "Iceland" — now collides with "a"
|
|
known,
|
|
testSlug,
|
|
);
|
|
expect(f.level).toBe('error');
|
|
});
|
|
|
|
it('says nothing about an id Immich does not know and has no override', () => {
|
|
const f = checkAlbumSlugCollisions(
|
|
[{ context: 'gallery.yaml albums', albumIds: ['a', 'ghost'] }],
|
|
{},
|
|
known,
|
|
testSlug,
|
|
);
|
|
expect(f.level).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('checkPasswords', () => {
|
|
it('passes when everything is hashed', () => {
|
|
expect(checkPasswords([{ label: 'Site', value: 'scrypt:aa:bb' }]).level).toBe('ok');
|
|
});
|
|
|
|
it('warns about plaintext and names where it lives', () => {
|
|
const f = checkPasswords([
|
|
{ label: 'Site', value: 'scrypt:aa:bb' },
|
|
{ label: 'Album japan-2024', value: 'hunter2' },
|
|
]);
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('Album japan-2024');
|
|
});
|
|
|
|
/** Bcrypt cannot be verified at all any more — that is broken, not sloppy. */
|
|
it('errors on a leftover bcrypt hash', () => {
|
|
expect(checkPasswords([{ label: 'Site', value: '$2b$10$abcdefghijklmno' }]).level).toBe(
|
|
'error',
|
|
);
|
|
});
|
|
|
|
it('never repeats a password', () => {
|
|
const f = checkPasswords([{ label: 'Album x', value: 'hunter2' }]);
|
|
expect(JSON.stringify(f)).not.toContain('hunter2');
|
|
});
|
|
});
|
|
|
|
describe('checkWritable', () => {
|
|
it('passes when nothing is blocked', () => {
|
|
expect(checkWritable([]).level).toBe('ok');
|
|
});
|
|
|
|
it('errors and names the paths', () => {
|
|
const f = checkWritable(['content/', 'content/.backups']);
|
|
expect(f.level).toBe('error');
|
|
expect(f.detail).toContain('content/.backups');
|
|
});
|
|
});
|
|
|
|
describe('checkImmichCalls', () => {
|
|
it('passes when every call came back', () => {
|
|
expect(
|
|
checkImmichCalls([
|
|
{ endpoint: '/server/ping', ok: true },
|
|
{ endpoint: '/albums', ok: true },
|
|
]).level,
|
|
).toBe('ok');
|
|
});
|
|
|
|
it('names the failing endpoint', () => {
|
|
const f = checkImmichCalls([
|
|
{ endpoint: '/server/ping', ok: true },
|
|
{ endpoint: '/albums', ok: false },
|
|
]);
|
|
expect(f.level).toBe('error');
|
|
expect(f.detail).toContain('/albums');
|
|
});
|
|
});
|
|
|
|
describe('worstLevel', () => {
|
|
it('reports the most severe finding', () => {
|
|
expect(worstLevel([{ id: 'a', level: 'ok', title: '', detail: '' }])).toBe('ok');
|
|
expect(
|
|
worstLevel([
|
|
{ id: 'a', level: 'ok', title: '', detail: '' },
|
|
{ id: 'b', level: 'warn', title: '', detail: '' },
|
|
]),
|
|
).toBe('warn');
|
|
expect(
|
|
worstLevel([
|
|
{ id: 'a', level: 'warn', title: '', detail: '' },
|
|
{ id: 'b', level: 'error', title: '', detail: '' },
|
|
]),
|
|
).toBe('error');
|
|
});
|
|
});
|
|
|
|
describe('checkCdn', () => {
|
|
it('stays silent without CDN_URL', () => {
|
|
expect(checkCdn(undefined, false, 1)).toBeNull();
|
|
});
|
|
|
|
it('warns that a site password keeps photos off the CDN', () => {
|
|
const finding = checkCdn('https://cdn.example.net', true, 1)!;
|
|
expect(finding.level).toBe('warn');
|
|
expect(finding.detail).toMatch(/password/);
|
|
});
|
|
|
|
it('warns when the rate limiter would count CDN edges', () => {
|
|
const finding = checkCdn('https://cdn.example.net', false, 0)!;
|
|
expect(finding.level).toBe('warn');
|
|
expect(finding.title).toMatch(/TRUSTED_PROXY_HOPS/);
|
|
});
|
|
|
|
it('reports ok with a proxy chain configured', () => {
|
|
const finding = checkCdn('https://cdn.example.net', false, 2)!;
|
|
expect(finding.level).toBe('ok');
|
|
expect(finding.detail).toContain('https://cdn.example.net');
|
|
});
|
|
});
|
|
|
|
describe('checkLegal', () => {
|
|
const complete = {
|
|
enabled: true,
|
|
name: 'Ralf',
|
|
address: 'Street 1',
|
|
zipCity: '10247 Berlin',
|
|
email: 'mail@example.com',
|
|
contactUrl: 'https://example.com/contact',
|
|
};
|
|
|
|
it('returns nothing while the Impressum is switched off or absent', () => {
|
|
expect(checkLegal({ ...complete, enabled: false })).toBeNull();
|
|
expect(checkLegal(undefined)).toBeNull();
|
|
});
|
|
|
|
it('passes with name, address, email and a second channel', () => {
|
|
expect(checkLegal(complete)?.level).toBe('ok');
|
|
expect(checkLegal({ ...complete, contactUrl: undefined, phone: '+49 30 1' })?.level).toBe('ok');
|
|
});
|
|
|
|
it('names the missing address fields', () => {
|
|
const f = checkLegal({ ...complete, address: '', zipCity: ' ' })!;
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('street address, ZIP and city');
|
|
});
|
|
|
|
it('asks for an email address', () => {
|
|
expect(checkLegal({ ...complete, email: undefined })!.detail).toContain('No email address');
|
|
});
|
|
|
|
it('flags email as the only contact channel', () => {
|
|
const f = checkLegal({ ...complete, contactUrl: undefined })!;
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('only contact channel');
|
|
});
|
|
|
|
it('reports a contact URL the page drops, and does not count it as a channel', () => {
|
|
const f = checkLegal({ ...complete, contactUrl: 'javascript:alert(1)' })!;
|
|
expect(f.level).toBe('warn');
|
|
expect(f.title).toContain('2 gaps');
|
|
expect(f.detail).toContain('ignored');
|
|
expect(f.detail).toContain('only contact channel');
|
|
});
|
|
|
|
it('tolerates hand-edited YAML with the wrong types', () => {
|
|
const f = checkLegal({ ...complete, name: 42, email: ['a'] })!;
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('Missing: name');
|
|
});
|
|
});
|
|
|
|
describe('checkLegal with the built-in contact form', () => {
|
|
const emailOnly = {
|
|
enabled: true,
|
|
name: 'Ralf',
|
|
address: 'Street 1',
|
|
zipCity: '10247 Berlin',
|
|
email: 'mail@example.com',
|
|
};
|
|
|
|
it('counts the form as the second contact channel', () => {
|
|
expect(checkLegal(emailOnly)?.level).toBe('warn');
|
|
expect(checkLegal(emailOnly, true)?.level).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('checkContact', () => {
|
|
it('returns nothing while the form is off', () => {
|
|
expect(checkContact({ enabled: false })).toBeNull();
|
|
expect(checkContact(undefined)).toBeNull();
|
|
});
|
|
|
|
it('warns when nobody hears about new messages', () => {
|
|
expect(checkContact({ enabled: true })?.level).toBe('warn');
|
|
});
|
|
|
|
it('warns about a URL that is not http(s)', () => {
|
|
const f = checkContact({ enabled: true, notifyUrl: 'ntfy.sh/topic' })!;
|
|
expect(f.level).toBe('warn');
|
|
expect(f.title).toContain('ignored');
|
|
});
|
|
|
|
it('is satisfied by the settings value or by CONTACT_NOTIFY_URL', () => {
|
|
expect(checkContact({ enabled: true, notifyUrl: 'https://ntfy.sh/t' })?.level).toBe('ok');
|
|
expect(checkContact({ enabled: true }, 'https://ntfy.sh/t')?.level).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('checkPrivacy', () => {
|
|
it('stays quiet on a site with no legal pages at all', () => {
|
|
expect(checkPrivacy({ legalEnabled: false, privacyEnabled: true, hasText: false })).toBeNull();
|
|
});
|
|
|
|
it('warns when the Impressum is on and there is no policy', () => {
|
|
const f = checkPrivacy({ legalEnabled: true, privacyEnabled: true, hasText: false })!;
|
|
expect(f.level).toBe('warn');
|
|
expect(f.detail).toContain('no text');
|
|
});
|
|
|
|
it('warns when the text exists but the page is switched off', () => {
|
|
const f = checkPrivacy({ legalEnabled: true, privacyEnabled: false, hasText: true })!;
|
|
expect(f.detail).toContain('switched off');
|
|
});
|
|
|
|
it('is satisfied by a published policy', () => {
|
|
expect(checkPrivacy({ legalEnabled: true, privacyEnabled: true, hasText: true })?.level).toBe(
|
|
'ok',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('checkContentPages (#722)', () => {
|
|
it('stays silent without pages or references', () => {
|
|
expect(checkContentPages({ menuRefs: [], pages: [], collisions: [] })).toBeNull();
|
|
});
|
|
|
|
it('is ok when every reference has a file and no slug is taken', () => {
|
|
const f = checkContentPages({
|
|
menuRefs: ['pricing'],
|
|
pages: ['pricing', 'faq'],
|
|
collisions: [],
|
|
});
|
|
expect(f?.level).toBe('ok');
|
|
expect(f?.id).toBe('content-pages');
|
|
});
|
|
|
|
it('warns about a menu reference without a file, naming it', () => {
|
|
const f = checkContentPages({
|
|
menuRefs: ['pricing', 'gone'],
|
|
pages: ['pricing'],
|
|
collisions: [],
|
|
});
|
|
expect(f?.level).toBe('warn');
|
|
expect(f?.detail).toContain('"gone"');
|
|
});
|
|
|
|
it('reports a slug collision as an error', () => {
|
|
const f = checkContentPages({
|
|
menuRefs: [],
|
|
pages: ['travel'],
|
|
collisions: [{ slug: 'travel', reason: '/travel is already a subpage.' }],
|
|
});
|
|
expect(f?.level).toBe('error');
|
|
expect(f?.detail).toContain('/travel is already a subpage.');
|
|
});
|
|
});
|
|
|
|
describe('checkSettingValues (QA A-14)', () => {
|
|
it('is ok when every checked value is used as written', () => {
|
|
expect(checkSettingValues([]).level).toBe('ok');
|
|
});
|
|
|
|
it('warns and names the fields the site replaces', () => {
|
|
const f = checkSettingValues([{ field: 'theme.accent' }, { field: 'grid.columns' }]);
|
|
expect(f.level).toBe('warn');
|
|
expect(f.title).toBe('2 settings are ignored');
|
|
expect(f.detail).toContain('accent colour, grid columns');
|
|
});
|
|
|
|
it('points at the settings section of the first field', () => {
|
|
expect(checkSettingValues([{ field: 'url' }]).settingsSection).toBe('seo');
|
|
expect(checkSettingValues([{ field: 'grid.gap' }]).settingsSection).toBe('grid');
|
|
expect(checkSettingValues([{ field: 'theme.accent' }]).settingsSection).toBe('theme');
|
|
expect(checkSettingValues([{ field: 'theme.radius' }]).settingsSection).toBe('theme');
|
|
expect(checkSettingValues([{ field: 'contact.retentionDays' }]).settingsSection).toBe('legal');
|
|
expect(checkSettingValues([{ field: 'navLinks.3.url' }]).settingsSection).toBe('footer');
|
|
});
|
|
|
|
it('counts the header links as one setting however many entries fail', () => {
|
|
const f = checkSettingValues([
|
|
{ field: 'navLinks.0.label' },
|
|
{ field: 'navLinks.0.url' },
|
|
{ field: 'navLinks.2.url' },
|
|
]);
|
|
expect(f.title).toBe('The header links setting is ignored');
|
|
});
|
|
});
|
|
|
|
describe('checkDownloadMetadata', () => {
|
|
const album = (id: string, uncleanable: number, uncleanableWithLocation = 0) => ({
|
|
id,
|
|
albumName: `Album ${id}`,
|
|
uncleanable,
|
|
uncleanableWithLocation,
|
|
});
|
|
|
|
it('says nothing while no album offers downloads', () => {
|
|
expect(checkDownloadMetadata([])).toBeNull();
|
|
});
|
|
|
|
it('is ok when every original can be cleaned', () => {
|
|
const f = checkDownloadMetadata([album('a', 0), album('b', 0)])!;
|
|
expect(f.level).toBe('ok');
|
|
expect(f.id).toBe('download-metadata');
|
|
expect(f.detail).toContain('2 download albums');
|
|
});
|
|
|
|
it('warns about originals served with all their metadata, and names the albums', () => {
|
|
const f = checkDownloadMetadata([album('a', 0), album('b', 3, 2), album('c', 1)])!;
|
|
expect(f.level).toBe('warn');
|
|
expect(f.title).toBe('4 downloadable originals keep all metadata');
|
|
expect(f.detail).toContain('GPS');
|
|
expect(f.detail).toContain('Immich has a location for 2 of them');
|
|
expect(f.detail).toContain('Album b (3)');
|
|
expect(f.albumIds).toEqual(['b', 'c']);
|
|
});
|
|
|
|
it('leaves out the location count when Immich knows none', () => {
|
|
const f = checkDownloadMetadata([album('a', 1)])!;
|
|
expect(f.title).toBe('1 downloadable original keeps all metadata');
|
|
expect(f.detail).not.toContain('Immich has a location');
|
|
});
|
|
});
|
|
|
|
describe('checkZoomRenditions (#467)', () => {
|
|
const album = (id: string, needRendition: number) => ({ id, albumName: id, needRendition });
|
|
|
|
it('says nothing while no zoom album needs a rendition, or nothing is known', () => {
|
|
expect(checkZoomRenditions([], 'missing')).toBeNull();
|
|
expect(checkZoomRenditions([album('a', 0)], 'missing')).toBeNull();
|
|
expect(checkZoomRenditions([album('a', 3)], null)).toBeNull();
|
|
});
|
|
|
|
it('is fine when Immich has JPEG renditions', () => {
|
|
expect(checkZoomRenditions([album('a', 3)], 'ok')?.level).toBe('ok');
|
|
});
|
|
|
|
it('warns, naming the albums, when Immich has none', () => {
|
|
const finding = checkZoomRenditions([album('a', 3), album('b', 0), album('c', 1)], 'missing')!;
|
|
expect(finding.level).toBe('warn');
|
|
expect(finding.title).toMatch(/^4 photos cannot be zoomed/);
|
|
expect(finding.detail).toContain('Full-size');
|
|
expect(finding.albumIds).toEqual(['a', 'c']);
|
|
});
|
|
|
|
it('warns about a WebP rendition, which the zoom route refuses (review of #830)', () => {
|
|
const finding = checkZoomRenditions([album('a', 2)], { contentType: 'image/webp' })!;
|
|
expect(finding.level).toBe('warn');
|
|
expect(finding.title).toMatch(/not JPEG/);
|
|
expect(finding.detail).toContain('image/webp');
|
|
expect(finding.detail).toMatch(/Format to JPEG/);
|
|
});
|
|
});
|