refactor(server): replace node-fetch with native fetch + undici, fix photo integrations
Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.
Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
This commit is contained in:
53
server/package-lock.json
generated
53
server/package-lock.json
generated
@@ -21,12 +21,12 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0",
|
||||
@@ -4409,26 +4409,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -5857,12 +5837,6 @@
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
@@ -5933,6 +5907,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
|
||||
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
@@ -6273,22 +6256,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"undici": "^7.0.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -35,7 +35,6 @@ import oidcRoutes from './routes/oidc';
|
||||
import vacayRoutes from './routes/vacay';
|
||||
import atlasRoutes from './routes/atlas';
|
||||
import memoriesRoutes from './routes/memories/unified';
|
||||
import immichRoutes from './routes/immich';
|
||||
import notificationRoutes from './routes/notifications';
|
||||
import shareRoutes from './routes/share';
|
||||
import { mcpHandler } from './mcp';
|
||||
@@ -258,8 +257,6 @@ export function createApp(): express.Application {
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
//old routes for immich integration (will be removed eventually)
|
||||
app.use('/api/integrations/immich', immichRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
//DEPRECATED - This route is no longer used use new routes
|
||||
|
||||
|
||||
|
||||
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
||||
import { getClientIp } from '../services/auditLog';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
saveImmichSettings,
|
||||
testConnection,
|
||||
getConnectionStatus,
|
||||
browseTimeline,
|
||||
searchPhotos,
|
||||
getAssetInfo,
|
||||
proxyThumbnail,
|
||||
proxyOriginal,
|
||||
isValidAssetId,
|
||||
listAlbums,
|
||||
listAlbumLinks,
|
||||
createAlbumLink,
|
||||
deleteAlbumLink,
|
||||
syncAlbumAssets,
|
||||
} from '../services/memories/immichService';
|
||||
import { addTripPhotos, listTripPhotos, removeTripPhoto, setTripPhotoSharing } from '../services/memories/unifiedService';
|
||||
import { Selection, canAccessUserPhoto } from '../services/memories/helpersService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── Dual auth middleware (JWT or ephemeral token for <img> src) ─────────────
|
||||
|
||||
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
||||
const queryToken = req.query.token as string | undefined;
|
||||
if (queryToken) {
|
||||
const userId = consumeEphemeralToken(queryToken, 'immich');
|
||||
if (!userId) return res.status(401).send('Invalid or expired token');
|
||||
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
|
||||
if (!user) return res.status(401).send('User not found');
|
||||
(req as AuthRequest).user = user;
|
||||
return next();
|
||||
}
|
||||
return (authenticate as any)(req, res, next);
|
||||
}
|
||||
|
||||
// ── Immich Connection Settings ─────────────────────────────────────────────
|
||||
|
||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(getConnectionSettings(authReq.user.id));
|
||||
});
|
||||
|
||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
if (result.warning) return res.json({ success: true, warning: result.warning });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(await getConnectionStatus(authReq.user.id));
|
||||
});
|
||||
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
|
||||
res.json(await testConnection(immich_url, immich_api_key));
|
||||
});
|
||||
|
||||
// ── Browse Immich Library (for photo picker) ───────────────────────────────
|
||||
|
||||
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await browseTimeline(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ buckets: result.buckets });
|
||||
});
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to } = req.body;
|
||||
const result = await searchPhotos(authReq.user.id, from, to);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
});
|
||||
|
||||
// ── Trip Photos (selected by user) ────────────────────────────────────────
|
||||
|
||||
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.json({ photos: listTripPhotos(tripId, authReq.user.id) });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const sid = req.headers['x-socket-id'] as string;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { asset_ids, shared = true } = req.body;
|
||||
|
||||
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'asset_ids required' });
|
||||
}
|
||||
|
||||
const selection: Selection = {
|
||||
provider: 'immich',
|
||||
asset_ids: asset_ids,
|
||||
};
|
||||
const result = await addTripPhotos(tripId, authReq.user.id, shared, [selection], sid);
|
||||
if ('error' in result) return res.status(result.error.status!).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/photos/:assetId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const result = await removeTripPhoto(req.params.tripId, authReq.user.id,'immich', req.params.assetId);
|
||||
if ('error' in result) return res.status(result.error.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { shared } = req.body;
|
||||
const result = await setTripPhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, 'immich', shared);
|
||||
if ('error' in result) return res.status(result.error.status!).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
||||
const tripId = req.query.tripId as string;
|
||||
if (ownerUserId && tripId && !canAccessUserPhoto(authReq.user.id, ownerUserId, tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await getAssetInfo(authReq.user.id, assetId, ownerUserId);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
// ── Proxy Immich Assets ────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
||||
const tripId = req.query.tripId as string;
|
||||
if (ownerUserId && tripId && !canAccessUserPhoto(authReq.user.id, ownerUserId, tripId, assetId, 'immich')) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
const result = await proxyThumbnail(authReq.user.id, assetId, ownerUserId);
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
});
|
||||
|
||||
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||
const queryUserId = req.query.userId ? Number(req.query.userId) : undefined;
|
||||
const ownerUserId = queryUserId && queryUserId !== authReq.user.id ? queryUserId : undefined;
|
||||
const tripId = req.query.tripId as string;
|
||||
if (ownerUserId && tripId && !canAccessUserPhoto(authReq.user.id, ownerUserId, tripId, assetId, 'immich')) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
const result = await proxyOriginal(authReq.user.id, assetId, ownerUserId);
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
});
|
||||
|
||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await listAlbums(authReq.user.id);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ albums: result.albums });
|
||||
});
|
||||
|
||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.json({ links: listAlbumLinks(req.params.tripId) });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { album_id, album_name } = req.body;
|
||||
if (!album_id) return res.status(400).json({ error: 'album_id required' });
|
||||
const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name);
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
const sid = req.headers['x-socket-id'] as string;
|
||||
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ success: true, added: result.added, total: result.total });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,9 +1,8 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { db, canAccessTrip } from '../../db/database';
|
||||
import express, { Request, Response } from 'express';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { authenticate } from '../../middleware/auth';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { AuthRequest } from '../../types';
|
||||
import { consumeEphemeralToken } from '../../services/ephemeralTokens';
|
||||
import { getClientIp } from '../../services/auditLog';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
@@ -12,30 +11,16 @@ import {
|
||||
getConnectionStatus,
|
||||
browseTimeline,
|
||||
searchPhotos,
|
||||
proxyThumbnail,
|
||||
proxyOriginal,
|
||||
streamImmichAsset,
|
||||
listAlbums,
|
||||
syncAlbumAssets,
|
||||
getAssetInfo,
|
||||
isValidAssetId,
|
||||
} from '../../services/memories/immichService';
|
||||
import { canAccessUserPhoto } from '../../services/memories/helpersService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── Dual auth middleware (JWT or ephemeral token for <img> src) ─────────────
|
||||
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
||||
const queryToken = req.query.token as string | undefined;
|
||||
if (queryToken) {
|
||||
const userId = consumeEphemeralToken(queryToken, 'immich');
|
||||
if (!userId) return res.status(401).send('Invalid or expired token');
|
||||
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
|
||||
if (!user) return res.status(401).send('User not found');
|
||||
(req as AuthRequest).user = user;
|
||||
return next();
|
||||
}
|
||||
return (authenticate as any)(req, res, next);
|
||||
}
|
||||
|
||||
// ── Immich Connection Settings ─────────────────────────────────────────────
|
||||
|
||||
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||
@@ -86,6 +71,7 @@ router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: R
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
@@ -96,32 +82,26 @@ router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: R
|
||||
|
||||
// ── Proxy Immich Assets ────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await proxyThumbnail(authReq.user.id, assetId, Number(ownerId));
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
await streamImmichAsset(res, authReq.user.id, assetId, 'thumbnail', Number(ownerId));
|
||||
});
|
||||
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||
router.get('/assets/:tripId/:assetId/:ownerId/original', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, assetId, ownerId } = req.params;
|
||||
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const result = await proxyOriginal(authReq.user.id, assetId, Number(ownerId));
|
||||
if (result.error) return res.status(result.status!).send(result.error);
|
||||
res.set('Content-Type', result.contentType!);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(result.buffer);
|
||||
await streamImmichAsset(res, authReq.user.id, assetId, 'original', Number(ownerId));
|
||||
});
|
||||
|
||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -113,7 +113,9 @@ router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: R
|
||||
router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, photoId, ownerId, kind } = req.params;
|
||||
const { size = "sm" } = req.query;
|
||||
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
|
||||
const rawSize = String(req.query.size ?? 'sm');
|
||||
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
|
||||
|
||||
if (kind !== 'thumbnail' && kind !== 'original') {
|
||||
return handleServiceResult(res, fail('Invalid asset kind', 400));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { Trip, Place } from '../types';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
@@ -983,7 +982,7 @@ export function createWsToken(userId: number): { error?: string; status?: number
|
||||
}
|
||||
|
||||
export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } {
|
||||
if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') {
|
||||
if (purpose !== 'download') {
|
||||
return { error: 'Invalid purpose', status: 400 };
|
||||
}
|
||||
const token = createEphemeralToken(userId, purpose);
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
|
||||
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Internal row types */
|
||||
@@ -400,17 +400,16 @@ export async function fetchLinkPreview(url: string): Promise<LinkPreviewResult>
|
||||
}
|
||||
|
||||
try {
|
||||
const nodeFetch = require('node-fetch');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const r: { ok: boolean; text: () => Promise<string> } = await nodeFetch(url, {
|
||||
const r = await fetch(url, {
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol),
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
|
||||
});
|
||||
} as any);
|
||||
clearTimeout(timeout);
|
||||
if (!r.ok) throw new Error('Fetch failed');
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import crypto from 'crypto';
|
||||
const TTL: Record<string, number> = {
|
||||
ws: 30_000,
|
||||
download: 60_000,
|
||||
immich: 60_000,
|
||||
synologyphotos: 60_000,
|
||||
};
|
||||
|
||||
const MAX_STORE_SIZE = 10_000;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Response } from 'express';
|
||||
import { canAccessTrip, db } from "../../db/database";
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard';
|
||||
|
||||
// helpers for handling return types
|
||||
|
||||
@@ -162,16 +162,9 @@ export function updateSyncTimeForAlbumLink(linkId: string): void {
|
||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||
}
|
||||
|
||||
export async function pipeAsset(url: string, response: Response): Promise<void> {
|
||||
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
|
||||
const SsrfResult = await checkSsrf(url);
|
||||
if (!SsrfResult.allowed) {
|
||||
response.status(400).json({ error: SsrfResult.error });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(url);
|
||||
const resp = await safeFetch(url, { headers, signal: signal as any });
|
||||
|
||||
response.status(resp.status);
|
||||
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
|
||||
@@ -181,14 +174,15 @@ export async function pipeAsset(url: string, response: Response): Promise<void>
|
||||
|
||||
if (!resp.body) {
|
||||
response.end();
|
||||
} else {
|
||||
await pipeline(Readable.fromWeb(resp.body as any), response);
|
||||
}
|
||||
else {
|
||||
pipeline(Readable.fromWeb(resp.body), response);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
if (response.headersSent) return;
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
response.status(400).json({ error: error.message });
|
||||
} else {
|
||||
response.status(500).json({ error: 'Failed to fetch asset' });
|
||||
response.end();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Response } from 'express';
|
||||
import { db } from '../../db/database';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
|
||||
import { writeAudit } from '../auditLog';
|
||||
import { addTripPhotos} from './unifiedService';
|
||||
import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService';
|
||||
import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection, pipeAsset } from './helpersService';
|
||||
|
||||
// ── Credentials ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getImmichCredentials(userId: number) {
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return null;
|
||||
return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string };
|
||||
const apiKey = decrypt_api_key(user.immich_api_key);
|
||||
if (!apiKey) return null;
|
||||
return { immich_url: user.immich_url as string, immich_api_key: apiKey };
|
||||
}
|
||||
|
||||
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
|
||||
@@ -75,9 +78,9 @@ export async function testConnection(
|
||||
const ssrf = await checkSsrf(immichUrl);
|
||||
if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' };
|
||||
try {
|
||||
const resp = await fetch(`${immichUrl}/api/users/me`, {
|
||||
const resp = await safeFetch(`${immichUrl}/api/users/me`, {
|
||||
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
});
|
||||
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
|
||||
const data = await resp.json() as { name?: string; email?: string };
|
||||
@@ -109,9 +112,9 @@ export async function getConnectionStatus(
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return { connected: false, error: 'Not configured' };
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/users/me`, {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/users/me`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
});
|
||||
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
|
||||
const data = await resp.json() as { name?: string; email?: string };
|
||||
@@ -130,10 +133,10 @@ export async function browseTimeline(
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/timeline/buckets`, {
|
||||
method: 'GET',
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status };
|
||||
const buckets = await resp.json();
|
||||
@@ -157,7 +160,7 @@ export async function searchPhotos(
|
||||
let page = 1;
|
||||
const pageSize = 1000;
|
||||
while (true) {
|
||||
const resp = await fetch(`${creds.immich_url}/api/search/metadata`, {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -167,7 +170,7 @@ export async function searchPhotos(
|
||||
size: pageSize,
|
||||
page,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
@@ -203,9 +206,9 @@ export async function getAssetInfo(
|
||||
if (!creds) return { error: 'Not found', status: 404 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/assets/${assetId}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed', status: resp.status };
|
||||
const asset = await resp.json() as any;
|
||||
@@ -235,50 +238,23 @@ export async function getAssetInfo(
|
||||
}
|
||||
}
|
||||
|
||||
export async function proxyThumbnail(
|
||||
export async function streamImmichAsset(
|
||||
response: Response,
|
||||
userId: number,
|
||||
assetId: string,
|
||||
kind: 'thumbnail' | 'original',
|
||||
ownerUserId?: number
|
||||
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
|
||||
): Promise<{ error?: string; status?: number } | void> {
|
||||
const effectiveUserId = ownerUserId ?? userId;
|
||||
const creds = getImmichCredentials(effectiveUserId);
|
||||
if (!creds) return { error: 'Not found', status: 404 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed', status: resp.status };
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
const contentType = resp.headers.get('content-type') || 'image/webp';
|
||||
return { buffer, contentType };
|
||||
} catch {
|
||||
return { error: 'Proxy error', status: 502 };
|
||||
}
|
||||
}
|
||||
const path = kind === 'thumbnail' ? 'thumbnail' : 'original';
|
||||
const timeout = kind === 'thumbnail' ? 10000 : 30000;
|
||||
const url = `${creds.immich_url}/api/assets/${assetId}/${path}`;
|
||||
|
||||
export async function proxyOriginal(
|
||||
userId: number,
|
||||
assetId: string,
|
||||
ownerUserId?: number
|
||||
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
|
||||
const effectiveUserId = ownerUserId ?? userId;
|
||||
const creds = getImmichCredentials(effectiveUserId);
|
||||
if (!creds) return { error: 'Not found', status: 404 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed', status: resp.status };
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
||||
return { buffer, contentType };
|
||||
} catch {
|
||||
return { error: 'Proxy error', status: 502 };
|
||||
}
|
||||
response.set('Cache-Control', 'public, max-age=86400');
|
||||
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout));
|
||||
}
|
||||
|
||||
// ── Albums ──────────────────────────────────────────────────────────────────
|
||||
@@ -290,9 +266,9 @@ export async function listAlbums(
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/albums`, {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
|
||||
const albums = (await resp.json() as any[]).map((a: any) => ({
|
||||
@@ -358,9 +334,9 @@ export async function syncAlbumAssets(
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums/${response.data}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Response } from 'express';
|
||||
import { db } from '../../db/database';
|
||||
import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto';
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { addTripPhotos } from './unifiedService';
|
||||
import {
|
||||
getAlbumIdFromLink,
|
||||
@@ -84,9 +84,6 @@ interface SynologyPhotoItem {
|
||||
|
||||
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
|
||||
try {
|
||||
|
||||
if (!columns) return null;
|
||||
|
||||
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||
|
||||
if (!row) {
|
||||
@@ -98,10 +95,6 @@ function _readSynologyUser(userId: number, columns: string[]): ServiceResult<Syn
|
||||
filtered[column] = row[column];
|
||||
}
|
||||
|
||||
if (!filtered) {
|
||||
return fail('Failed to read Synology user data', 500);
|
||||
}
|
||||
|
||||
return success(filtered);
|
||||
} catch {
|
||||
return fail('Failed to read Synology user data', 500);
|
||||
@@ -112,10 +105,12 @@ function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredenti
|
||||
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
||||
if (!user.success) return user as ServiceResult<SynologyCredentials>;
|
||||
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
|
||||
const password = decrypt_api_key(user.data.synology_password);
|
||||
if (!password) return fail('Synology credentials corrupted', 500);
|
||||
return success({
|
||||
synology_url: user.data.synology_url,
|
||||
synology_username: user.data.synology_username,
|
||||
synology_password: decrypt_api_key(user.data.synology_password) as string,
|
||||
synology_password: password,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,30 +131,26 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
|
||||
|
||||
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
|
||||
const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`);
|
||||
const SsrfResult = await checkSsrf(endpoint);
|
||||
if (!SsrfResult.allowed) {
|
||||
return fail(SsrfResult.error, 400);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(endpoint, {
|
||||
const resp = await safeFetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: AbortSignal.timeout(30000) as any,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return fail('Synology API request failed with status ' + resp.status, resp.status);
|
||||
}
|
||||
const response = await resp.json() as SynologyApiResponse<T>;
|
||||
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
|
||||
} catch (error) {
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
return fail(error.message, 400);
|
||||
}
|
||||
catch {
|
||||
return fail('Failed to connect to Synology API', 500);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
|
||||
@@ -196,11 +187,12 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
|
||||
|
||||
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
|
||||
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body);
|
||||
if ('error' in result && result.error.status === 119) {
|
||||
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
|
||||
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
|
||||
_clearSynologySID(userId);
|
||||
const retrySession = await _getSynologySession(userId);
|
||||
if (!retrySession.success || !retrySession.data) {
|
||||
return session as ServiceResult<T>;
|
||||
return retrySession as ServiceResult<T>;
|
||||
}
|
||||
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
|
||||
}
|
||||
@@ -240,7 +232,10 @@ function _clearSynologySID(userId: number): void {
|
||||
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
|
||||
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
|
||||
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
|
||||
// The first segment must be a non-empty integer (the unit ID used for API calls).
|
||||
if (!/^\d+_.+$/.test(rawId)) return null;
|
||||
const id = rawId.split('_')[0];
|
||||
return { id, cacheKey: rawId, assetId: rawId };
|
||||
}
|
||||
@@ -249,7 +244,9 @@ async function _getSynologySession(userId: number): Promise<ServiceResult<string
|
||||
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
|
||||
if (cachedSid.success && cachedSid.data?.synology_sid) {
|
||||
const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid);
|
||||
return success(decryptedSid);
|
||||
if (decryptedSid) return success(decryptedSid);
|
||||
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
|
||||
_clearSynologySID(userId);
|
||||
}
|
||||
|
||||
const creds = _getSynologyCredentials(userId);
|
||||
@@ -416,22 +413,24 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
|
||||
}
|
||||
}
|
||||
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
|
||||
if (!result.success) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>;
|
||||
// SYNO.Foto.Search.Search list_item does not return a total count — only data.list.
|
||||
// hasMore is inferred: if we got a full page, there may be more.
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
|
||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||
|
||||
const allItems = result.data.list || [];
|
||||
const total = allItems.length;
|
||||
const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item));
|
||||
|
||||
return success({
|
||||
assets,
|
||||
total,
|
||||
hasMore: total === limit,
|
||||
total: allItems.length,
|
||||
hasMore: allItems.length === limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<ServiceResult<AssetInfo>> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) return fail('Invalid photo ID format', 400);
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'get',
|
||||
@@ -459,6 +458,10 @@ export async function streamSynologyAsset(
|
||||
size?: string,
|
||||
): Promise<void> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) {
|
||||
handleServiceResult(response, fail('Invalid photo ID format', 400));
|
||||
return;
|
||||
}
|
||||
|
||||
const synology_credentials = _getSynologyCredentials(targetUserId);
|
||||
if (!synology_credentials.success) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import fetch from 'node-fetch';
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { logInfo, logDebug, logError } from './auditLog';
|
||||
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -351,14 +350,13 @@ export async function sendWebhook(url: string, payload: { event: string; title:
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = createPinnedAgent(ssrf.resolvedIp!, new URL(url).protocol);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: buildWebhookBody(url, payload),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
agent,
|
||||
});
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
} as any);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from 'crypto';
|
||||
import fetch from 'node-fetch';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
// ── Interfaces ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import dns from 'node:dns/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { Agent } from 'undici';
|
||||
|
||||
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true';
|
||||
|
||||
@@ -106,22 +105,46 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an http/https Agent whose `lookup` function is pinned to the
|
||||
* already-validated IP. This prevents DNS rebinding (TOCTOU) by ensuring
|
||||
* the outbound connection goes to the IP we checked, not a re-resolved one.
|
||||
* Thrown by safeFetch() when the URL is blocked by the SSRF guard.
|
||||
*/
|
||||
export function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent {
|
||||
const options = {
|
||||
export class SsrfBlockedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SsrfBlockedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes
|
||||
* the request using a DNS-pinned dispatcher so the resolved IP cannot change
|
||||
* between the check and the actual connection (DNS rebinding prevention).
|
||||
*/
|
||||
export async function safeFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
|
||||
}
|
||||
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!);
|
||||
return fetch(url, { ...init, dispatcher } as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an undici Agent whose connect.lookup is pinned to the already-validated
|
||||
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
|
||||
* goes to the IP we checked, not a re-resolved one.
|
||||
*/
|
||||
export function createPinnedDispatcher(resolvedIp: string): Agent {
|
||||
return new Agent({
|
||||
connect: {
|
||||
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
|
||||
// Determine address family from IP format
|
||||
const family = resolvedIp.includes(':') ? 6 : 4;
|
||||
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
|
||||
if (opts && opts.all) {
|
||||
if (opts?.all) {
|
||||
callback(null, [{ address: resolvedIp, family }]);
|
||||
} else {
|
||||
callback(null, resolvedIp, family);
|
||||
}
|
||||
},
|
||||
};
|
||||
return protocol === 'https:' ? new https.Agent(options) : new http.Agent(options);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { encryptMfaSecret } from '../../src/services/mfaCrypto';
|
||||
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
|
||||
|
||||
let _userSeq = 0;
|
||||
let _tripSeq = 0;
|
||||
@@ -506,3 +507,75 @@ export function disableNotificationPref(
|
||||
'INSERT OR REPLACE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 0)'
|
||||
).run(userId, eventType, channel);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Photo integration helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestTripPhoto {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
user_id: number;
|
||||
asset_id: string;
|
||||
provider: string;
|
||||
shared: number;
|
||||
album_link_id: number | null;
|
||||
}
|
||||
|
||||
export function addTripPhoto(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
userId: number,
|
||||
assetId: string,
|
||||
provider: string,
|
||||
opts: { shared?: boolean; albumLinkId?: number } = {}
|
||||
): TestTripPhoto {
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, assetId, provider, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
|
||||
return db.prepare('SELECT * FROM trip_photos WHERE id = ?').get(result.lastInsertRowid) as TestTripPhoto;
|
||||
}
|
||||
|
||||
export interface TestAlbumLink {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
user_id: number;
|
||||
provider: string;
|
||||
album_id: string;
|
||||
album_name: string;
|
||||
}
|
||||
|
||||
export function addAlbumLink(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
userId: number,
|
||||
provider: string,
|
||||
albumId: string,
|
||||
albumName = 'Test Album'
|
||||
): TestAlbumLink {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, provider, albumId, albumName);
|
||||
return db.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as TestAlbumLink;
|
||||
}
|
||||
|
||||
export function setImmichCredentials(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
url: string,
|
||||
apiKey: string
|
||||
): void {
|
||||
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?')
|
||||
.run(url, encrypt_api_key(apiKey), userId);
|
||||
}
|
||||
|
||||
export function setSynologyCredentials(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
url: string,
|
||||
username: string,
|
||||
password: string
|
||||
): void {
|
||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?')
|
||||
.run(url, username, encrypt_api_key(password), userId);
|
||||
}
|
||||
|
||||
@@ -91,12 +91,22 @@ const DEFAULT_ADDONS = [
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
];
|
||||
|
||||
const DEFAULT_PHOTO_PROVIDERS = [
|
||||
{ id: 'immich', name: 'Immich', enabled: 1 },
|
||||
{ id: 'synologyphotos', name: 'Synology Photos', enabled: 1 },
|
||||
];
|
||||
|
||||
function seedDefaults(db: Database.Database): void {
|
||||
const insertCat = db.prepare('INSERT OR IGNORE INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
||||
for (const cat of DEFAULT_CATEGORIES) insertCat.run(cat.name, cat.color, cat.icon);
|
||||
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of DEFAULT_ADDONS) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
|
||||
try {
|
||||
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (const p of DEFAULT_PHOTO_PROVIDERS) insertProvider.run(p.id, p.name, p.id, 'Image', p.enabled, 0);
|
||||
} catch { /* table may not exist in very old schemas */ }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich integration tests.
|
||||
* Covers IMMICH-001 to IMMICH-015 (settings, SSRF protection, connection test).
|
||||
* Covers IMMICH-001 to IMMICH-024 (settings, SSRF protection, album links).
|
||||
*
|
||||
* External Immich API calls are not made — tests focus on settings persistence
|
||||
* and input validation.
|
||||
@@ -60,6 +60,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
|
||||
}
|
||||
}),
|
||||
safeFetch: vi.fn().mockRejectedValue(new Error('safeFetch should not be called in unit tests')),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -89,43 +90,43 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('Immich settings', () => {
|
||||
it('IMMICH-001 — GET /api/immich/settings returns current settings', async () => {
|
||||
it('IMMICH-001 — GET /api/integrations/memories/immich/settings returns current settings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/integrations/immich/settings')
|
||||
.get('/api/integrations/memories/immich/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
// Settings may be empty initially
|
||||
expect(res.body).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-001 — PUT /api/immich/settings saves Immich URL and API key', async () => {
|
||||
it('IMMICH-001 — PUT /api/integrations/memories/immich/settings saves Immich URL and API key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.put('/api/integrations/memories/immich/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'test-api-key' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('IMMICH-002 — PUT /api/immich/settings with private IP is blocked by SSRF guard', async () => {
|
||||
it('IMMICH-002 — PUT /api/integrations/memories/immich/settings with private IP is blocked by SSRF guard', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.put('/api/integrations/memories/immich/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'http://192.168.1.100', immich_api_key: 'test-key' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('IMMICH-002 — PUT /api/immich/settings with loopback is blocked', async () => {
|
||||
it('IMMICH-002 — PUT /api/integrations/memories/immich/settings with loopback is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.put('/api/integrations/memories/immich/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'http://127.0.0.1:2283', immich_api_key: 'test-key' });
|
||||
expect(res.status).toBe(400);
|
||||
@@ -133,14 +134,14 @@ describe('Immich settings', () => {
|
||||
});
|
||||
|
||||
describe('Immich authentication', () => {
|
||||
it('GET /api/immich/settings without auth returns 401', async () => {
|
||||
const res = await request(app).get('/api/integrations/immich/settings');
|
||||
it('GET /api/integrations/memories/immich/settings without auth returns 401', async () => {
|
||||
const res = await request(app).get('/api/integrations/memories/immich/settings');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('PUT /api/immich/settings without auth returns 401', async () => {
|
||||
it('PUT /api/integrations/memories/immich/settings without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/integrations/immich/settings')
|
||||
.put('/api/integrations/memories/immich/settings')
|
||||
.send({ url: 'https://example.com', api_key: 'key' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
@@ -152,9 +153,9 @@ describe('Immich album links', () => {
|
||||
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/integrations/immich/trips/${trip.id}/album-links`)
|
||||
.post(`/api/integrations/memories/unified/trips/${trip.id}/album-links`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ album_id: 'album-uuid-123', album_name: 'Vacation 2024' });
|
||||
.send({ album_id: 'album-uuid-123', album_name: 'Vacation 2024', provider: 'immich' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -171,7 +172,7 @@ describe('Immich album links', () => {
|
||||
testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?)').run(trip.id, user.id, 'album-abc', 'My Album', 'immich');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/integrations/immich/trips/${trip.id}/album-links`)
|
||||
.get(`/api/integrations/memories/unified/trips/${trip.id}/album-links`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -196,7 +197,7 @@ describe('Immich album links', () => {
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, 1)').run(trip.id, user.id, 'asset-manual', 'immich');
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/integrations/immich/trips/${trip.id}/album-links/${linkResult.id}`)
|
||||
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
@@ -212,7 +213,7 @@ describe('Immich album links', () => {
|
||||
expect(link).toBeUndefined();
|
||||
});
|
||||
|
||||
it('IMMICH-023 — DELETE album-link by non-owner is a no-op', async () => {
|
||||
it('IMMICH-023 — DELETE album-link by non-member returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(owner.id, 'Test Trip') as any;
|
||||
@@ -221,12 +222,12 @@ describe('Immich album links', () => {
|
||||
.get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, owner.id, 'asset-owned', 'immich', linkResult.id);
|
||||
|
||||
// Other user tries to delete owner's album link
|
||||
// Non-member tries to delete owner's album link — should be denied
|
||||
const res = await request(app)
|
||||
.delete(`/api/integrations/immich/trips/${trip.id}/album-links/${linkResult.id}`)
|
||||
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
|
||||
expect(res.status).toBe(200); // endpoint returns 200 even when no row matched
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
// Link and photos should still exist
|
||||
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id);
|
||||
@@ -236,7 +237,7 @@ describe('Immich album links', () => {
|
||||
});
|
||||
|
||||
it('IMMICH-024 — DELETE album-link without auth returns 401', async () => {
|
||||
const res = await request(app).delete('/api/integrations/immich/trips/1/album-links/1');
|
||||
const res = await request(app).delete('/api/integrations/memories/unified/trips/1/album-links/1');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
513
server/tests/integration/memories-immich.test.ts
Normal file
513
server/tests/integration/memories-immich.test.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* Immich-specific integration tests (IMMICH-030 – IMMICH-070).
|
||||
* Covers status, test-connection, browse, search, asset proxy, access control,
|
||||
* and albums — everything NOT covered by the existing immich.test.ts.
|
||||
*
|
||||
* safeFetch is mocked to return fake Immich API responses based on URL patterns.
|
||||
* No real HTTP calls are made.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
// ── SSRF guard mock — routes all Immich API calls to fake responses ───────────
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||||
|
||||
function makeFakeImmichFetch(url: string, init?: any) {
|
||||
const u = typeof url === 'string' ? url : String(url);
|
||||
|
||||
// /api/users/me — used by status + test-connection
|
||||
if (u.includes('/api/users/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null },
|
||||
json: () => Promise.resolve({ name: 'Test User', email: 'test@immich.local' }),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/timeline/buckets — browse
|
||||
if (u.includes('/api/timeline/buckets')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve([{ timeBucket: '2024-01-01T00:00:00.000Z', count: 3 }]),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/search/metadata — search
|
||||
if (u.includes('/api/search/metadata')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: [
|
||||
{ id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/assets/:id/thumbnail — thumbnail proxy
|
||||
if (u.includes('/thumbnail')) {
|
||||
const imageBytes = Buffer.from('fake-thumbnail-data');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'image/webp' : null },
|
||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
||||
});
|
||||
}
|
||||
// /api/assets/:id/original — original proxy
|
||||
if (u.includes('/original')) {
|
||||
const imageBytes = Buffer.from('fake-original-data');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
|
||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
||||
});
|
||||
}
|
||||
// /api/assets/:id — asset info
|
||||
if (/\/api\/assets\/[^/]+$/.test(u)) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
id: 'asset-info-1',
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
originalFileName: 'photo.jpg',
|
||||
exifInfo: {
|
||||
exifImageWidth: 4032, exifImageHeight: 3024,
|
||||
make: 'Apple', model: 'iPhone 15',
|
||||
lensModel: null, focalLength: 5.1, fNumber: 1.8,
|
||||
exposureTime: '1/500', iso: 100,
|
||||
city: 'Paris', state: 'Île-de-France', country: 'France',
|
||||
latitude: 48.8566, longitude: 2.3522,
|
||||
fileSizeInByte: 2048000,
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/albums — list albums
|
||||
if (/\/api\/albums$/.test(u)) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve([
|
||||
{ id: 'album-uuid-1', albumName: 'Vacation 2024', assetCount: 42, startDate: '2024-06-01', endDate: '2024-06-14', albumThumbnailAssetId: null },
|
||||
]),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/albums/:id — album detail (for sync)
|
||||
if (/\/api\/albums\//.test(u)) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({ assets: [{ id: 'asset-sync-1', type: 'IMAGE' }] }),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// fallback — unexpected call
|
||||
return Promise.reject(new Error(`Unexpected safeFetch call: ${u}`));
|
||||
}
|
||||
|
||||
return {
|
||||
...actual,
|
||||
checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const h = url.hostname;
|
||||
if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
|
||||
return { allowed: false, isPrivate: true, error: 'Loopback not allowed' };
|
||||
}
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
|
||||
return { allowed: false, isPrivate: true, error: 'Private IP not allowed' };
|
||||
}
|
||||
return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
|
||||
}
|
||||
}),
|
||||
safeFetch: vi.fn().mockImplementation(makeFakeImmichFetch),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
const IMMICH = '/api/integrations/memories/immich';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
// ── Connection status ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich connection status', () => {
|
||||
it('IMMICH-030 — GET /status when not configured returns { connected: false }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it('IMMICH-031 — GET /status when configured returns connected + user info', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
expect(res.body.user).toMatchObject({ name: 'Test User', email: 'test@immich.local' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test connection ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich test connection', () => {
|
||||
it('IMMICH-032 — POST /test with missing fields returns { connected: false }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'https://immich.example.com' }); // missing api_key
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it('IMMICH-033 — POST /test with valid credentials returns { connected: true }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
expect(res.body.user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Browse & Search ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich browse and search', () => {
|
||||
it('IMMICH-040 — GET /browse when not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/browse`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('IMMICH-041 — GET /browse returns timeline buckets', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/browse`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.buckets)).toBe(true);
|
||||
expect(res.body.buckets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('IMMICH-042 — POST /search returns mapped assets', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' });
|
||||
});
|
||||
|
||||
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('upstream unreachable'));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Asset proxy ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich asset proxy', () => {
|
||||
it('IMMICH-050 — GET /assets/info returns asset metadata for own photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-info-1', 'immich', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/asset-info-1/${user.id}/info`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ id: 'asset-info-1', city: 'Paris', country: 'France' });
|
||||
});
|
||||
|
||||
it('IMMICH-051 — GET /assets/info with invalid assetId (special chars) returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// ID contains characters outside [a-zA-Z0-9_-] → fails isValidAssetId()
|
||||
const invalidId = 'asset!@#$%';
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/${encodeURIComponent(invalidId)}/${user.id}/info`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('IMMICH-052 — GET /assets/info by non-owner of unshared photo returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
|
||||
// private photo — shared = false
|
||||
addTripPhoto(testDb, trip.id, owner.id, 'asset-private', 'immich', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/asset-private/${owner.id}/info`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('IMMICH-053 — GET /assets/info by trip member for shared photo returns 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
|
||||
// shared photo
|
||||
addTripPhoto(testDb, trip.id, owner.id, 'asset-shared', 'immich', { shared: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/asset-shared/${owner.id}/info`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('IMMICH-054 — GET /assets/thumbnail for own photo streams image data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-thumb', 'immich', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/asset-thumb/${user.id}/thumbnail`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/webp');
|
||||
expect(res.body).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-055 — GET /assets/thumbnail for other\'s unshared photo returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
addTripPhoto(testDb, trip.id, owner.id, 'asset-noshare', 'immich', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/asset-noshare/${owner.id}/thumbnail`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('IMMICH-056 — GET /assets/original for shared photo streams image data', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
|
||||
addTripPhoto(testDb, trip.id, owner.id, 'asset-orig', 'immich', { shared: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/asset-orig/${owner.id}/original`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
});
|
||||
|
||||
it('IMMICH-057 — GET /assets/info where trip does not exist returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
|
||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||
testDb.prepare(
|
||||
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(9999, owner.id, 'asset-notrip', 'immich', 1);
|
||||
testDb.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/9999/asset-notrip/${owner.id}/info`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
// canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('IMMICH-058 — GET /assets/info when upstream returns error propagates status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-upstream-err', 'immich', { shared: false });
|
||||
|
||||
vi.mocked(safeFetch).mockResolvedValueOnce({
|
||||
ok: false, status: 503,
|
||||
headers: { get: () => null } as any,
|
||||
json: async () => ({}),
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/assets/${trip.id}/asset-upstream-err/${user.id}/info`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Albums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich albums', () => {
|
||||
it('IMMICH-060 — GET /albums when not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('IMMICH-061 — GET /albums returns album list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums[0]).toMatchObject({ id: 'album-uuid-1', albumName: 'Vacation 2024' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auth checks ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich auth checks', () => {
|
||||
it('IMMICH-070 — GET /status without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${IMMICH}/status`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('IMMICH-070 — POST /test without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${IMMICH}/test`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('IMMICH-070 — GET /browse without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${IMMICH}/browse`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('IMMICH-070 — POST /search without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${IMMICH}/search`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('IMMICH-070 — GET /albums without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${IMMICH}/albums`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('IMMICH-070 — GET /assets/info without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/info`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('IMMICH-070 — GET /assets/thumbnail without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/thumbnail`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('IMMICH-070 — GET /assets/original without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/original`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
545
server/tests/integration/memories-synology.test.ts
Normal file
545
server/tests/integration/memories-synology.test.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Synology Photos integration tests (SYNO-001 – SYNO-040).
|
||||
* Covers settings, connection test, search, albums, asset streaming, and access control.
|
||||
*
|
||||
* safeFetch is mocked to return fake Synology API JSON responses based on the `api`
|
||||
* query/body parameter. The Synology service uses POST form-body requests so the mock
|
||||
* inspects URLSearchParams to dispatch the right fake response.
|
||||
*
|
||||
* No real HTTP calls are made.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
// ── SSRF guard mock — routes all Synology API calls to fake responses ─────────
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||||
|
||||
function makeFakeSynologyFetch(url: string, init?: any) {
|
||||
const u = String(url);
|
||||
|
||||
// Determine which API was called from the URL query param (e.g. ?api=SYNO.API.Auth)
|
||||
// or from the body for POST requests.
|
||||
let apiName = '';
|
||||
try {
|
||||
apiName = new URL(u).searchParams.get('api') || '';
|
||||
} catch {}
|
||||
if (!apiName && init?.body) {
|
||||
const body = init.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init.body));
|
||||
apiName = body.get('api') || '';
|
||||
}
|
||||
|
||||
// Auth login — used by settings save, status, test-connection
|
||||
if (apiName === 'SYNO.API.Auth') {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: () => Promise.resolve({ success: true, data: { sid: 'fake-session-id-abc' } }),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Album list
|
||||
if (apiName === 'SYNO.Foto.Browse.Album') {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
list: [
|
||||
{ id: 1, name: 'Summer Trip', item_count: 15 },
|
||||
{ id: 2, name: 'Winter Holiday', item_count: 8 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Search photos
|
||||
if (apiName === 'SYNO.Foto.Search.Search') {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
id: 101,
|
||||
filename: 'photo1.jpg',
|
||||
filesize: 1024000,
|
||||
time: 1717228800, // 2024-06-01 in Unix timestamp
|
||||
additional: {
|
||||
thumbnail: { cache_key: '101_cachekey' },
|
||||
address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' },
|
||||
exif: { camera: 'Sony A7IV', focal_length: '50', aperture: '1.8', exposure_time: '1/250', iso: 400 },
|
||||
gps: { latitude: 35.6762, longitude: 139.6503 },
|
||||
resolution: { width: 6000, height: 4000 },
|
||||
orientation: 1,
|
||||
description: 'Tokyo street',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Browse items (for album sync or asset info)
|
||||
if (apiName === 'SYNO.Foto.Browse.Item') {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
id: 101,
|
||||
filename: 'photo1.jpg',
|
||||
filesize: 1024000,
|
||||
time: 1717228800,
|
||||
additional: {
|
||||
thumbnail: { cache_key: '101_cachekey' },
|
||||
address: { city: 'Tokyo', country: 'Japan', state: 'Tokyo' },
|
||||
exif: { camera: 'Sony A7IV' },
|
||||
gps: { latitude: 35.6762, longitude: 139.6503 },
|
||||
resolution: { width: 6000, height: 4000 },
|
||||
orientation: 1,
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Thumbnail stream
|
||||
if (apiName === 'SYNO.Foto.Thumbnail') {
|
||||
const imageBytes = Buffer.from('fake-synology-thumbnail');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
|
||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
||||
});
|
||||
}
|
||||
|
||||
// Original download
|
||||
if (apiName === 'SYNO.Foto.Download') {
|
||||
const imageBytes = Buffer.from('fake-synology-original');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
|
||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
|
||||
}
|
||||
|
||||
return {
|
||||
...actual,
|
||||
checkSsrf: vi.fn().mockImplementation(async (rawUrl: string) => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const h = url.hostname;
|
||||
if (h === '127.0.0.1' || h === '::1' || h === 'localhost') {
|
||||
return { allowed: false, isPrivate: true, error: 'Loopback not allowed' };
|
||||
}
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) {
|
||||
return { allowed: false, isPrivate: true, error: 'Private IP not allowed' };
|
||||
}
|
||||
return { allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' };
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
|
||||
}
|
||||
}),
|
||||
safeFetch: vi.fn().mockImplementation(makeFakeSynologyFetch),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
const SYNO = '/api/integrations/memories/synologyphotos';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology settings', () => {
|
||||
it('SYNO-001 — GET /settings when not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/settings`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('SYNO-002 — PUT /settings saves credentials and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${SYNO}/settings`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
synology_url: 'https://synology.example.com',
|
||||
synology_username: 'admin',
|
||||
synology_password: 'secure-password',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = testDb.prepare('SELECT synology_url, synology_username FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.synology_url).toBe('https://synology.example.com');
|
||||
expect(row.synology_username).toBe('admin');
|
||||
});
|
||||
|
||||
it('SYNO-003 — PUT /settings with SSRF-blocked URL returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${SYNO}/settings`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
synology_url: 'http://192.168.1.100',
|
||||
synology_username: 'admin',
|
||||
synology_password: 'pass',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('SYNO-004 — PUT /settings without URL returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${SYNO}/settings`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ synology_username: 'admin', synology_password: 'pass' }); // no url
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Connection ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology connection', () => {
|
||||
it('SYNO-010 — GET /status when not configured returns { connected: false }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-011 — GET /status when configured returns { connected: true }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
});
|
||||
|
||||
it('SYNO-012 — POST /test with valid credentials returns { connected: true }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
synology_url: 'https://synology.example.com',
|
||||
synology_username: 'admin',
|
||||
synology_password: 'secure-password',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
});
|
||||
|
||||
it('SYNO-013 — POST /test with missing fields returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ synology_url: 'https://synology.example.com' }); // missing username+password
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(false);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search & Albums ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology search and albums', () => {
|
||||
it('SYNO-020 — POST /search returns mapped assets', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
expect(res.body.assets[0]).toMatchObject({ city: 'Tokyo', country: 'Japan' });
|
||||
});
|
||||
|
||||
it('SYNO-021 — POST /search when upstream throws propagates 500', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
// Auth call succeeds, search call throws a network error
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockRejectedValueOnce(new Error('Synology unreachable'));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('SYNO-022 — GET /albums returns album list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums).toHaveLength(2);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Summer Trip', assetCount: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Asset access ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology asset access', () => {
|
||||
it('SYNO-030 — GET /assets/info returns metadata for own photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ city: 'Tokyo', country: 'Japan' });
|
||||
});
|
||||
|
||||
it('SYNO-031 — GET /assets/info by non-owner of unshared photo returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/info`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('SYNO-032 — GET /assets/thumbnail streams image data for own photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/thumbnail`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
});
|
||||
|
||||
it('SYNO-033 — GET /assets/original streams image data for shared photo', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
addTripPhoto(testDb, trip.id, owner.id, '101_cachekey', 'synologyphotos', { shared: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${owner.id}/original`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
});
|
||||
|
||||
it('SYNO-034 — GET /assets with invalid kind returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/badkind`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('SYNO-035 — GET /assets/info where trip does not exist returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
|
||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||
testDb.prepare(
|
||||
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(9999, owner.id, '101_cachekey', 'synologyphotos', 1);
|
||||
testDb.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/assets/9999/101_cachekey/${owner.id}/info`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
// canAccessUserPhoto: shared photo found, but canAccessTrip(9999) → null → false → 403
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('SYNO-036 — GET /assets/info when upstream throws propagates 500', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
addTripPhoto(testDb, trip.id, user.id, '101_cachekey', 'synologyphotos', { shared: false });
|
||||
|
||||
// Auth call succeeds, Browse.Item call throws a network error
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockRejectedValueOnce(new Error('network failure'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/assets/${trip.id}/101_cachekey/${user.id}/info`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auth checks ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology auth checks', () => {
|
||||
it('SYNO-040 — GET /settings without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${SYNO}/settings`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-040 — PUT /settings without auth returns 401', async () => {
|
||||
expect((await request(app).put(`${SYNO}/settings`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-040 — GET /status without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${SYNO}/status`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-040 — POST /test without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${SYNO}/test`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-040 — GET /albums without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${SYNO}/albums`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-040 — POST /search without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${SYNO}/search`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-040 — GET /assets/info without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/info`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-040 — GET /assets/thumbnail without auth returns 401', async () => {
|
||||
expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/thumbnail`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
334
server/tests/integration/memories-unified.test.ts
Normal file
334
server/tests/integration/memories-unified.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Unified Memories integration tests (UNIFIED-001 – UNIFIED-020).
|
||||
* Covers the provider-agnostic /unified/trips/:tripId/photos and
|
||||
* /unified/trips/:tripId/album-links routes.
|
||||
*
|
||||
* No real HTTP is made — safeFetch is mocked to never be called.
|
||||
* The broadcast WebSocket call is no-op mocked.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||||
return {
|
||||
...actual,
|
||||
checkSsrf: vi.fn().mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' }),
|
||||
safeFetch: vi.fn().mockRejectedValue(new Error('safeFetch should not be called in unified tests')),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
const BASE = '/api/integrations/memories/unified';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function photosUrl(tripId: number) { return `${BASE}/trips/${tripId}/photos`; }
|
||||
function albumLinksUrl(tripId: number, linkId?: number) {
|
||||
return linkId ? `${BASE}/trips/${tripId}/album-links/${linkId}` : `${BASE}/trips/${tripId}/album-links`;
|
||||
}
|
||||
|
||||
// ── Unified Photo Management ─────────────────────────────────────────────────
|
||||
|
||||
describe('Unified photo management', () => {
|
||||
it('UNIFIED-001 — GET photos lists own + shared photos from other members', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// owner has a private photo; member has a shared photo
|
||||
addTripPhoto(testDb, trip.id, owner.id, 'asset-own', 'immich', { shared: false });
|
||||
addTripPhoto(testDb, trip.id, member.id, 'asset-shared', 'immich', { shared: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
|
||||
expect(ids).toContain('asset-own');
|
||||
expect(ids).toContain('asset-shared');
|
||||
});
|
||||
|
||||
it('UNIFIED-002 — GET photos excludes other members\' private photos', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
addTripPhoto(testDb, trip.id, member.id, 'asset-private', 'immich', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
|
||||
expect(ids).not.toContain('asset-private');
|
||||
});
|
||||
|
||||
it('UNIFIED-003 — GET photos returns 404 for non-member', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('UNIFIED-004 — POST photos adds photos from selections', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
shared: true,
|
||||
selections: [{ provider: 'immich', asset_ids: ['asset-a', 'asset-b'] }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.added).toBe(2);
|
||||
|
||||
const rows = testDb.prepare('SELECT asset_id FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
|
||||
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
|
||||
});
|
||||
|
||||
it('UNIFIED-005 — POST photos with empty selections returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ selections: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('UNIFIED-006 — POST photos with invalid provider returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ selections: [{ provider: 'nonexistent', asset_ids: ['asset-x'] }] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('UNIFIED-007 — PUT photos/sharing toggles shared flag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${photosUrl(trip.id)}/sharing`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'asset-tog', shared: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare('SELECT shared FROM trip_photos WHERE asset_id = ?').get('asset-tog') as any;
|
||||
expect(row.shared).toBe(1);
|
||||
});
|
||||
|
||||
it('UNIFIED-008 — PUT photos/sharing on non-member trip returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${photosUrl(trip.id)}/sharing`)
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({ provider: 'immich', asset_id: 'any', shared: true });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('UNIFIED-009 — DELETE photos removes own photo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
|
||||
|
||||
const res = await request(app)
|
||||
.delete(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'asset-del' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-del');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('UNIFIED-010 — DELETE photos on non-member trip returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({ provider: 'immich', asset_id: 'any' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unified Album-Link Management ────────────────────────────────────────────
|
||||
|
||||
describe('Unified album-link management', () => {
|
||||
it('UNIFIED-011 — POST album-links with missing provider returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(albumLinksUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ album_id: 'album-abc', album_name: 'Test' }); // no provider
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('UNIFIED-012 — POST album-links with missing album_id returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(albumLinksUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', album_name: 'Test' }); // no album_id
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('UNIFIED-013 — POST album-links duplicate link returns 409', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(albumLinksUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(albumLinksUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('UNIFIED-014 — GET album-links only returns links for enabled providers', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-enabled');
|
||||
|
||||
// Disable the immich provider
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 0 WHERE id = ?').run('immich');
|
||||
|
||||
const res = await request(app)
|
||||
.get(albumLinksUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// Re-enable for future tests
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich');
|
||||
|
||||
expect(res.status).toBe(400); // no providers enabled → error
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auth checks ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Unified auth checks', () => {
|
||||
it('UNIFIED-020 — GET photos without auth returns 401', async () => {
|
||||
const res = await request(app).get(`${BASE}/trips/1/photos`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('UNIFIED-020 — POST photos without auth returns 401', async () => {
|
||||
const res = await request(app).post(`${BASE}/trips/1/photos`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('UNIFIED-020 — PUT photos/sharing without auth returns 401', async () => {
|
||||
const res = await request(app).put(`${BASE}/trips/1/photos/sharing`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('UNIFIED-020 — DELETE photos without auth returns 401', async () => {
|
||||
const res = await request(app).delete(`${BASE}/trips/1/photos`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('UNIFIED-020 — GET album-links without auth returns 401', async () => {
|
||||
const res = await request(app).get(`${BASE}/trips/1/album-links`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('UNIFIED-020 — POST album-links without auth returns 401', async () => {
|
||||
const res = await request(app).post(`${BASE}/trips/1/album-links`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('UNIFIED-020 — DELETE album-links without auth returns 401', async () => {
|
||||
const res = await request(app).delete(`${BASE}/trips/1/album-links/1`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -37,14 +37,12 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock external holiday API (node-fetch used by some service paths)
|
||||
vi.mock('node-fetch', () => ({
|
||||
default: vi.fn().mockResolvedValue({
|
||||
// Prevent real HTTP calls (holiday API etc.)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([
|
||||
{ date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' },
|
||||
]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock vacayService.getCountries to avoid real HTTP call to nager.at
|
||||
@@ -81,6 +79,7 @@ beforeEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('Vacay plan', () => {
|
||||
|
||||
@@ -39,9 +39,8 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Mock node-fetch / global fetch so no real HTTP calls are made
|
||||
vi.mock('node-fetch', () => ({
|
||||
default: vi.fn().mockResolvedValue({
|
||||
// Prevent real HTTP calls to Open-Meteo
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
|
||||
@@ -56,7 +55,6 @@ vi.mock('node-fetch', () => ({
|
||||
sunset: ['2025-06-01T21:00'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
@@ -82,6 +80,7 @@ beforeEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('Weather validation', () => {
|
||||
|
||||
@@ -47,11 +47,11 @@ vi.mock('nodemailer', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node-fetch', () => ({ default: fetchMock }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
||||
createPinnedAgent: vi.fn(() => ({})),
|
||||
createPinnedDispatcher: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
@@ -109,6 +109,7 @@ beforeEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, afterAll, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
|
||||
@@ -16,12 +16,12 @@ vi.mock('../../../src/services/auditLog', () => ({
|
||||
getClientIp: vi.fn(),
|
||||
}));
|
||||
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
|
||||
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
||||
createPinnedAgent: vi.fn(() => ({})),
|
||||
createPinnedDispatcher: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications';
|
||||
@@ -253,7 +253,7 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
|
||||
});
|
||||
|
||||
it('allows a public URL and calls fetch', async () => {
|
||||
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
||||
|
||||
@@ -306,7 +306,7 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
|
||||
});
|
||||
|
||||
it('does not call fetch when SSRF check blocks the URL', async () => {
|
||||
const mockFetch = (await import('node-fetch')).default as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
mockFetch.mockClear();
|
||||
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
||||
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
|
||||
@@ -317,3 +317,5 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals());
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
// Prevent the module-level setInterval from running during tests
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock node-fetch to prevent real HTTP requests
|
||||
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||
// Prevent real HTTP requests
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals());
|
||||
|
||||
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user