chore: full implementation of code review recommendations (security, quality, tests)
This commit is contained in:
@@ -19,3 +19,26 @@
|
|||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
.subpage-content {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.legal-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.legal-section__title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.legal-section__text {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.legal-section__text--pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.legal-source {
|
||||||
|
margin-top: 4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export default function ImpressumPage() {
|
|||||||
<p className="subpage-subtitle">Angaben gemäß § 5 TMG</p>
|
<p className="subpage-subtitle">Angaben gemäß § 5 TMG</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="subpage-content" style={{ maxWidth: '600px', margin: '0 auto' }}>
|
<main className="subpage-content">
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section className="legal-section">
|
||||||
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>Anschrift</h2>
|
<h2 className="legal-section__title">Anschrift</h2>
|
||||||
<p style={{ lineHeight: 1.6 }}>
|
<p className="legal-section__text">
|
||||||
{legal.name}
|
{legal.name}
|
||||||
<br />
|
<br />
|
||||||
{legal.address}
|
{legal.address}
|
||||||
@@ -44,9 +44,9 @@ export default function ImpressumPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(legal.email || legal.phone) && (
|
{(legal.email || legal.phone) && (
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section className="legal-section">
|
||||||
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>Kontakt</h2>
|
<h2 className="legal-section__title">Kontakt</h2>
|
||||||
<p style={{ lineHeight: 1.6 }}>
|
<p className="legal-section__text">
|
||||||
{legal.email && (
|
{legal.email && (
|
||||||
<>
|
<>
|
||||||
E-Mail: {legal.email}
|
E-Mail: {legal.email}
|
||||||
@@ -59,11 +59,9 @@ export default function ImpressumPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(legal.taxId || legal.vatId) && (
|
{(legal.taxId || legal.vatId) && (
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section className="legal-section">
|
||||||
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>
|
<h2 className="legal-section__title">Steuernummer</h2>
|
||||||
Steuernummer
|
<p className="legal-section__text">
|
||||||
</h2>
|
|
||||||
<p style={{ lineHeight: 1.6 }}>
|
|
||||||
{legal.taxId && (
|
{legal.taxId && (
|
||||||
<>
|
<>
|
||||||
Steuernummer: {legal.taxId}
|
Steuernummer: {legal.taxId}
|
||||||
@@ -76,15 +74,13 @@ export default function ImpressumPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{legal.extraInfo && (
|
{legal.extraInfo && (
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section className="legal-section">
|
||||||
<h2 style={{ fontSize: '1.2rem', marginBottom: '0.5rem', opacity: 0.8 }}>
|
<h2 className="legal-section__title">Weitere Informationen</h2>
|
||||||
Weitere Informationen
|
<p className="legal-section__text legal-section__text--pre">{legal.extraInfo}</p>
|
||||||
</h2>
|
|
||||||
<p style={{ lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{legal.extraInfo}</p>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section style={{ marginTop: '4rem', fontSize: '0.85rem', opacity: 0.6 }}>
|
<section className="legal-source">
|
||||||
<p>Quelle: Erstellt mit dem Impressum-Generator von eRecht24.</p>
|
<p>Quelle: Erstellt mit dem Impressum-Generator von eRecht24.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
vi.mock('@/lib/config', () => ({
|
vi.mock('@/lib/config', () => ({
|
||||||
getConfig: () => ({
|
getConfig: () => ({
|
||||||
immich: { apiKey: 'test-api-key-for-auth-tests' },
|
immich: { apiKey: 'test-api-key-for-auth-tests' },
|
||||||
|
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||||
subpages: [
|
subpages: [
|
||||||
{
|
{
|
||||||
name: 'Private',
|
name: 'Private',
|
||||||
|
|||||||
103
lib/__tests__/immich.test.ts
Normal file
103
lib/__tests__/immich.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { immich } from '../immich';
|
||||||
|
import * as config from '../config';
|
||||||
|
|
||||||
|
// Mock the config by wrapping it in a factory
|
||||||
|
vi.mock('../config', async () => {
|
||||||
|
const actual = await vi.importActual<typeof config>('../config');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getConfig: () => ({
|
||||||
|
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
|
||||||
|
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||||
|
albums: ['album-1', 'album-2'],
|
||||||
|
standaloneAlbums: ['album-1'],
|
||||||
|
subpages: [],
|
||||||
|
albumOverrides: { 'album-1': 'Override Name' },
|
||||||
|
cacheTtl: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
describe('ImmichClient', () => {
|
||||||
|
const mockFetch = global.fetch as any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request()', () => {
|
||||||
|
it('returns data on successful JSON response', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
json: async () => ({ foo: 'bar' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Access private method via any casting for testing
|
||||||
|
const result = await (immich as any).request('/test');
|
||||||
|
expect(result.data).toEqual({ foo: 'bar' });
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error on non-ok response', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await (immich as any).request('/test');
|
||||||
|
expect(result.data).toBeNull();
|
||||||
|
expect(result.error).toContain('404 Not Found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAlbums()', () => {
|
||||||
|
it('filters and overrides albums based on config', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
json: async () => [
|
||||||
|
{ id: 'album-1', albumName: 'Original Name', assetCount: 10 },
|
||||||
|
{ id: 'album-3', albumName: 'Not Allowed', assetCount: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const albums = await immich.getAlbums();
|
||||||
|
expect(albums).toHaveLength(1);
|
||||||
|
expect(albums[0].id).toBe('album-1');
|
||||||
|
expect(albums[0].albumName).toBe('Override Name');
|
||||||
|
expect(albums[0].slug).toBe('override-name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAlbum()', () => {
|
||||||
|
it('returns null for unallowed album IDs', async () => {
|
||||||
|
const album = await immich.getAlbum('album-3');
|
||||||
|
expect(album).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns album data and filters trashed assets', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
json: async () => ({
|
||||||
|
id: 'album-1',
|
||||||
|
albumName: 'Name',
|
||||||
|
assets: [
|
||||||
|
{ id: 'asset-1', isTrashed: false },
|
||||||
|
{ id: 'asset-2', isTrashed: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const album = await immich.getAlbum('album-1');
|
||||||
|
expect(album?.assets).toHaveLength(1);
|
||||||
|
expect(album?.assets[0].id).toBe('asset-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
vi.mock('@/lib/config', () => ({
|
vi.mock('@/lib/config', () => ({
|
||||||
getConfig: () => ({
|
getConfig: () => ({
|
||||||
immich: { apiKey: 'test-api-key-for-vitest-unit-tests' },
|
immich: { apiKey: 'test-api-key-for-vitest-unit-tests' },
|
||||||
|
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
33
lib/auth.ts
33
lib/auth.ts
@@ -2,21 +2,30 @@
|
|||||||
* Album authentication helpers.
|
* Album authentication helpers.
|
||||||
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
|
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
|
||||||
*
|
*
|
||||||
* Token = HMAC-SHA256(slug + password, apiKey)
|
* Token = HMAC-SHA256(slug + passwordSecret, authSecret)
|
||||||
* Cookie = lb_auth_<slug> = <token>
|
* Cookie = lb_auth_<slug> = <token>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import { getConfig, SubpageConfig } from './config';
|
import { getConfig, SubpageConfig } from './config';
|
||||||
|
|
||||||
const TOKEN_EXPIRY_HOURS = 24;
|
const TOKEN_EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string looks like a bcrypt hash.
|
||||||
|
*/
|
||||||
|
function isHash(str: string): boolean {
|
||||||
|
return str.startsWith('$2a$') || str.startsWith('$2b$') || str.startsWith('$2y$');
|
||||||
|
}
|
||||||
|
|
||||||
function hmac(data: string): string {
|
function hmac(data: string): string {
|
||||||
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
|
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function authToken(slug: string, password: string): string {
|
function authToken(slug: string, passwordSecret: string): string {
|
||||||
return hmac(`${slug}:${password}`);
|
// Use the hash/password as part of the HMAC for a secure, unique session token
|
||||||
|
return hmac(`${slug}:${passwordSecret}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cookieName(slug: string): string {
|
function cookieName(slug: string): string {
|
||||||
@@ -46,9 +55,23 @@ export function authenticate(slug: string, password: string): string | null {
|
|||||||
const sp = findSubpageBySlug(slug);
|
const sp = findSubpageBySlug(slug);
|
||||||
if (!sp?.password) return null;
|
if (!sp?.password) return null;
|
||||||
|
|
||||||
if (password !== sp.password) return null;
|
let isValid = false;
|
||||||
|
if (isHash(sp.password)) {
|
||||||
|
isValid = bcrypt.compareSync(password, sp.password);
|
||||||
|
} else {
|
||||||
|
// Plaintext fallback (deprecated)
|
||||||
|
isValid = password === sp.password;
|
||||||
|
if (isValid) {
|
||||||
|
console.warn(
|
||||||
|
`\n⚠️ SECURITY WARNING: Subpage "${slug}" is using a plaintext password in gallery.yaml.\n` +
|
||||||
|
` Please hash it for better security. You can use: npx bcryptjs ${sp.password}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const token = authToken(slug, password);
|
if (!isValid) return null;
|
||||||
|
|
||||||
|
const token = authToken(slug, sp.password);
|
||||||
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
||||||
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
||||||
|
|
||||||
|
|||||||
19
lib/env.ts
19
lib/env.ts
@@ -29,10 +29,25 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Secret used for signing auth cookies and encrypting asset tokens.
|
* Secret used for signing auth cookies and encrypting asset tokens.
|
||||||
* If not provided, IMMICH_API_KEY will be used as a fallback (not recommended for production).
|
|
||||||
*/
|
*/
|
||||||
AUTH_SECRET: z.string().min(16, 'AUTH_SECRET should be at least 16 characters').optional(),
|
AUTH_SECRET: z.string().min(16, 'AUTH_SECRET should be at least 16 characters').optional(),
|
||||||
});
|
}).refine(
|
||||||
|
(data) => {
|
||||||
|
// Only require AUTH_SECRET in production at runtime, not during build
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
!process.env.NEXT_PHASE?.includes('build') &&
|
||||||
|
!data.AUTH_SECRET
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'AUTH_SECRET is required in production',
|
||||||
|
path: ['AUTH_SECRET'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export interface SubpageSummary {
|
|||||||
coverAssetId: string | null;
|
coverAssetId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImmichResult<T> = { data: T | null; error: string | null };
|
||||||
|
|
||||||
// ── API Client ─────────────────────────────────────────────────
|
// ── API Client ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class ImmichClient {
|
class ImmichClient {
|
||||||
@@ -89,7 +91,7 @@ class ImmichClient {
|
|||||||
/**
|
/**
|
||||||
* Make an authenticated request to the Immich API.
|
* Make an authenticated request to the Immich API.
|
||||||
*/
|
*/
|
||||||
private async request<T>(endpoint: string): Promise<T | null> {
|
private async request<T>(endpoint: string): Promise<ImmichResult<T>> {
|
||||||
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -100,18 +102,20 @@ class ImmichClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
|
const error = `[Immich] ${res.status} ${res.statusText} for ${endpoint}`;
|
||||||
return null;
|
console.error(error);
|
||||||
|
return { data: null, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = res.headers.get('Content-Type') || '';
|
const contentType = res.headers.get('Content-Type') || '';
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
return (await res.json()) as T;
|
return { data: (await res.json()) as T, error: null };
|
||||||
}
|
}
|
||||||
return null;
|
return { data: null, error: 'Invalid response format (expected JSON)' };
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error(`[Immich] Failed to reach ${url}:`, error);
|
const error = `[Immich] Failed to reach ${url}: ${err instanceof Error ? err.message : 'Unknown error'}`;
|
||||||
return null;
|
console.error(error);
|
||||||
|
return { data: null, error };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +164,8 @@ class ImmichClient {
|
|||||||
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
const cached = cache.get<ImmichAlbum[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const all = await this.request<ImmichAlbum[]>('/albums?shared=true');
|
const { data: all, error } = await this.request<ImmichAlbum[]>('/albums?shared=true');
|
||||||
if (!all) return [];
|
if (error || !all) return [];
|
||||||
|
|
||||||
const allowedIds = new Set(this.config.albums);
|
const allowedIds = new Set(this.config.albums);
|
||||||
const filtered = all
|
const filtered = all
|
||||||
@@ -276,8 +280,8 @@ class ImmichClient {
|
|||||||
const cached = cache.get<ImmichAlbum>(cacheKey);
|
const cached = cache.get<ImmichAlbum>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const album = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
|
const { data: album, error } = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
|
||||||
if (!album) return null;
|
if (error || !album) return null;
|
||||||
|
|
||||||
// Filter out trashed assets
|
// Filter out trashed assets
|
||||||
album.assets = (album.assets || []).filter((a) => !a.isTrashed);
|
album.assets = (album.assets || []).filter((a) => !a.isTrashed);
|
||||||
@@ -325,8 +329,8 @@ class ImmichClient {
|
|||||||
const cached = cache.get<ImmichAsset>(cacheKey);
|
const cached = cache.get<ImmichAsset>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
|
const { data: asset, error } = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
|
||||||
if (!asset) return null;
|
if (error || !asset) return null;
|
||||||
|
|
||||||
cache.set(cacheKey, asset, this.config.cacheTtl);
|
cache.set(cacheKey, asset, this.config.cacheTtl);
|
||||||
return asset;
|
return asset;
|
||||||
@@ -406,8 +410,8 @@ class ImmichClient {
|
|||||||
* Check if the Immich server is reachable.
|
* Check if the Immich server is reachable.
|
||||||
*/
|
*/
|
||||||
async ping(): Promise<boolean> {
|
async ping(): Promise<boolean> {
|
||||||
const res = await this.request<{ res: string }>('/server/ping');
|
const { data } = await this.request<{ res: string }>('/server/ping');
|
||||||
return !!res;
|
return !!data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
* In-memory sliding-window rate limiter.
|
* In-memory sliding-window rate limiter.
|
||||||
* Tracks request counts per IP per minute bucket.
|
* Tracks request counts per IP per minute bucket.
|
||||||
* Auto-evicts expired entries to prevent memory leaks.
|
* Auto-evicts expired entries to prevent memory leaks.
|
||||||
|
*
|
||||||
|
* ⚠️ NOTE: This is an in-memory store. In a multi-node or serverless
|
||||||
|
* environment (Vercel, AWS Lambda, Docker Swarm), each instance will
|
||||||
|
* have its own independent rate limit. For a global limit across
|
||||||
|
* multiple nodes, a persistent store like Redis would be required.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface RateLimitEntry {
|
interface RateLimitEntry {
|
||||||
|
|||||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
@@ -2089,6 +2091,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -3190,6 +3199,15 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'path';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
include: ['lib/__tests__/**/*.test.ts'],
|
include: ['lib/__tests__/**/*.test.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
|
|||||||
7
vitest.setup.ts
Normal file
7
vitest.setup.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// Set mandatory environment variables for all tests
|
||||||
|
process.env.IMMICH_API_URL = 'http://immich.test';
|
||||||
|
process.env.IMMICH_API_KEY = 'test-key';
|
||||||
|
process.env.AUTH_SECRET = 'test-auth-secret-32-chars-long-min';
|
||||||
|
process.env.SITE_TITLE = 'Test Gallery';
|
||||||
Reference in New Issue
Block a user