Merge pull request #437 from mauriceboe/feat/migrate-node-fetch-to-native
refactor(server): replace node-fetch with native fetch + undici, fix photo integrations
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,33 +162,27 @@ 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> {
|
||||
try{
|
||||
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
const resp = await safeFetch(url, { headers, signal: signal as any });
|
||||
|
||||
const SsrfResult = await checkSsrf(url);
|
||||
if (!SsrfResult.allowed) {
|
||||
response.status(400).json({ error: SsrfResult.error });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(url);
|
||||
|
||||
response.status(resp.status);
|
||||
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
|
||||
if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
|
||||
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
|
||||
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
|
||||
|
||||
|
||||
if (!resp.body) {
|
||||
response.end();
|
||||
} else {
|
||||
await pipeline(Readable.fromWeb(resp.body as any), response);
|
||||
}
|
||||
else {
|
||||
pipeline(Readable.fromWeb(resp.body), response);
|
||||
} 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' });
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
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 {
|
||||
} catch (error) {
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
return fail(error.message, 400);
|
||||
}
|
||||
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 = {
|
||||
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) {
|
||||
callback(null, [{ address: resolvedIp, family }]);
|
||||
} else {
|
||||
callback(null, resolvedIp, family);
|
||||
}
|
||||
},
|
||||
};
|
||||
return protocol === 'https:' ? new https.Agent(options) : new http.Agent(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) => {
|
||||
const family = resolvedIp.includes(':') ? 6 : 4;
|
||||
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
|
||||
if (opts?.all) {
|
||||
callback(null, [{ address: resolvedIp, family }]);
|
||||
} else {
|
||||
callback(null, resolvedIp, family);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([
|
||||
{ date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' },
|
||||
]),
|
||||
}),
|
||||
// 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,23 +39,21 @@ 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({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
|
||||
daily: {
|
||||
time: ['2025-06-01'],
|
||||
temperature_2m_max: [25],
|
||||
temperature_2m_min: [18],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [15],
|
||||
sunrise: ['2025-06-01T06:00'],
|
||||
sunset: ['2025-06-01T21:00'],
|
||||
},
|
||||
}),
|
||||
// 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 },
|
||||
daily: {
|
||||
time: ['2025-06-01'],
|
||||
temperature_2m_max: [25],
|
||||
temperature_2m_min: [18],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [15],
|
||||
sunrise: ['2025-06-01T06:00'],
|
||||
sunset: ['2025-06-01T21:00'],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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