diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d9b0ac4..6aa5ec0 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -13,7 +13,7 @@ concurrency: jobs: e2e: name: E2E Tests - if: github.event_name == 'push' || github.event.label.name == 'run-e2e' + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'run-e2e' runs-on: ubuntu-latest timeout-minutes: 60 @@ -48,6 +48,7 @@ jobs: run: | TEST_USER="kima_e2e" TEST_PASS="$(openssl rand -hex 20)" + echo "::add-mask::${TEST_PASS}" echo "KIMA_TEST_USERNAME=${TEST_USER}" >> "$GITHUB_ENV" echo "KIMA_TEST_PASSWORD=${TEST_PASS}" >> "$GITHUB_ENV" KIMA_CONTAINER=kima-e2e \ diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9e2c254..78b11b2 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -5,6 +5,10 @@ on: - cron: "0 3 * * *" workflow_dispatch: +concurrency: + group: nightly + cancel-in-progress: true + jobs: full-e2e: name: Full E2E Suite @@ -44,6 +48,7 @@ jobs: # Generate random credentials for this run -- no hardcoded passwords in source. TEST_USER="kima_e2e" TEST_PASS="$(openssl rand -hex 20)" + echo "::add-mask::${TEST_PASS}" echo "KIMA_TEST_USERNAME=${TEST_USER}" >> "$GITHUB_ENV" echo "KIMA_TEST_PASSWORD=${TEST_PASS}" >> "$GITHUB_ENV" KIMA_CONTAINER=kima-nightly \ diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 306c65a..8bfb94b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -65,6 +65,7 @@ jobs: run: | TEST_USER="kima_e2e" TEST_PASS="$(openssl rand -hex 20)" + echo "::add-mask::${TEST_PASS}" echo "KIMA_TEST_USERNAME=${TEST_USER}" >> "$GITHUB_ENV" echo "KIMA_TEST_PASSWORD=${TEST_PASS}" >> "$GITHUB_ENV" KIMA_CONTAINER=kima-security \ diff --git a/backend/src/routes/browse.ts b/backend/src/routes/browse.ts index 8d4b60f..296e887 100644 --- a/backend/src/routes/browse.ts +++ b/backend/src/routes/browse.ts @@ -137,7 +137,7 @@ router.get("/playlists/:id", async (req, res) => { * GET /api/browse/radios * Get all radio stations (mood/theme based mixes) */ -router.get("/radios", async (req, res) => { +router.get("/radios", async (_req, res) => { try { logger.debug("[Browse] Fetching radio stations..."); const radios = await deezerService.getRadioStations(); @@ -156,7 +156,7 @@ router.get("/radios", async (req, res) => { * GET /api/browse/radios/by-genre * Get radio stations organized by genre */ -router.get("/radios/by-genre", async (req, res) => { +router.get("/radios/by-genre", async (_req, res) => { try { logger.debug("[Browse] Fetching radios by genre..."); const genresWithRadios = await deezerService.getRadiosByGenre(); @@ -207,7 +207,7 @@ router.get("/radios/:id", async (req, res) => { * GET /api/browse/genres * Get all available genres */ -router.get("/genres", async (req, res) => { +router.get("/genres", async (_req, res) => { try { logger.debug("[Browse] Fetching genres..."); const genres = await deezerService.getGenres(); @@ -311,8 +311,31 @@ router.post("/playlists/parse", async (req, res) => { }); } - return res.status(400).json({ - error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL." + // Try YouTube / YouTube Music -- extract list= query param as playlist ID + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + const isYouTube = + hostname.includes("youtube.com") || + hostname.includes("youtu.be") || + hostname.includes("music.youtube.com"); + if (isYouTube) { + const listId = parsed.searchParams.get("list"); + if (listId) { + return res.json({ + source: "youtube", + type: "playlist", + id: listId, + url, + }); + } + } + } catch { + // Invalid URL -- fall through to error + } + + return res.status(400).json({ + error: "Invalid or unsupported URL. Please provide a Spotify, Deezer, or YouTube playlist URL." }); } catch (error) { safeError(res, "Parse URL", error); @@ -324,7 +347,7 @@ router.post("/playlists/parse", async (req, res) => { * Get a combined view of featured content (playlists, genres) * Note: Radio stations are now internal (library-based), not from Deezer */ -router.get("/all", async (req, res) => { +router.get("/all", async (_req, res) => { try { logger.debug("[Browse] Fetching browse content (playlists + genres)..."); diff --git a/backend/src/routes/downloads.ts b/backend/src/routes/downloads.ts index 4f68826..3312486 100644 --- a/backend/src/routes/downloads.ts +++ b/backend/src/routes/downloads.ts @@ -158,7 +158,7 @@ router.post("/", async (req, res) => { // Single album download - verify artist name before proceeding let verifiedArtistName = artistName; if (type === "album" && artistName) { - const verification = await verifyArtistName(artistName, mbid); + const verification = await verifyArtistName(artistName); if (verification.wasCorrected) { logger.debug( `[DOWNLOAD] Artist name verified: "${artistName}" → "${verification.verifiedName}" (source: ${verification.source})` @@ -241,7 +241,6 @@ router.post("/", async (req, res) => { type, mbid, subject, - rootFolderPath, verifiedArtistName, albumTitle ).catch((error) => { @@ -486,7 +485,6 @@ async function processArtistDownload( "album", albumMbid, albumSubject, - rootFolderPath, artistName, albumTitle ).catch((error) => { @@ -508,7 +506,6 @@ async function processDownload( type: string, mbid: string, subject: string, - rootFolderPath: string, artistName?: string, albumTitle?: string ) { @@ -576,7 +573,7 @@ router.delete("/clear-all", async (req, res) => { }); // POST /downloads/clear-lidarr-queue - Clear stuck/failed items from Lidarr's queue -router.post("/clear-lidarr-queue", async (req, res) => { +router.post("/clear-lidarr-queue", async (_req, res) => { try { const result = await simpleDownloadManager.clearLidarrQueue(); res.json({ diff --git a/backend/src/services/spotify.ts b/backend/src/services/spotify.ts index 0afaec0..63839de 100644 --- a/backend/src/services/spotify.ts +++ b/backend/src/services/spotify.ts @@ -761,7 +761,7 @@ class SpotifyService { description: playlist.description, owner: playlist.owner?.display_name || "Unknown", imageUrl: playlist.images?.[0]?.url || null, - trackCount: playlist.tracks?.total || tracks.length, + trackCount: tracks.length, tracks, isPublic: playlist.public ?? true, }; diff --git a/frontend/features/library/components/LibraryTabs.tsx b/frontend/features/library/components/LibraryTabs.tsx index 4d24b7f..9e7230c 100644 --- a/frontend/features/library/components/LibraryTabs.tsx +++ b/frontend/features/library/components/LibraryTabs.tsx @@ -20,7 +20,7 @@ export function LibraryTabs({ activeTab, onTabChange }: LibraryTabsProps) {
{/* Tab buttons */} -
+
{tabs.map((tab, index) => { const isActive = activeTab === tab.id; const Icon = tab.icon; @@ -62,10 +62,9 @@ export function LibraryTabs({ activeTab, onTabChange }: LibraryTabsProps) { {!isActive && (
)} diff --git a/frontend/features/vibe/VibeMap.tsx b/frontend/features/vibe/VibeMap.tsx index 256f543..c0bf129 100644 --- a/frontend/features/vibe/VibeMap.tsx +++ b/frontend/features/vibe/VibeMap.tsx @@ -227,8 +227,8 @@ export function VibeMap({ data: labels, getPosition: (d) => [d.x, d.y], getText: (d) => d.label, - getSize: labelZoom < 7 ? 13 : 10, - getColor: [255, 255, 255, labelZoom < 7 ? 50 : 35], + getSize: labelZoom < 7 ? 15 : 12, + getColor: [255, 255, 255, labelZoom < 7 ? 70 : 50], fontFamily: "Montserrat, system-ui, sans-serif", fontWeight: 500, getTextAnchor: "middle" as const, diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index f7a0c2c..1d7c78e 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -68,40 +68,22 @@ export const getApiBaseUrl = () => { return process.env.BACKEND_URL || "http://127.0.0.1:3006"; } - // Explicit env var takes precedence + // Explicit env var: used for reverse proxies where the API is on a different origin if (process.env.NEXT_PUBLIC_API_URL) { return process.env.NEXT_PUBLIC_API_URL; } - // Docker all-in-one mode: Use relative URLs (Next.js rewrites will proxy) - // This is detected by checking if we're on the same port as the frontend - const frontendPort = - window.location.port || - (window.location.protocol === "https:" ? "443" : "80"); - if ( - frontendPort === "3030" || - frontendPort === "443" || - frontendPort === "80" - ) { - // Use relative paths - Next.js rewrites will proxy to backend - return ""; - } - - // Development mode: Backend on separate port - const currentHost = window.location.hostname; - const apiPort = "3006"; - return `${window.location.protocol}//${currentHost}:${apiPort}`; + // Default: relative URLs so Next.js rewrites proxy to the backend. + // Works for any host port mapping (e.g. -p 9000:3030) because the browser + // origin matches whatever port the user mapped -- no hardcoded port needed. + return ""; }; class ApiClient { - private baseUrl: string; private token: string | null = null; private tokenInitialized: boolean = false; - constructor(baseUrl?: string) { - // Don't set baseUrl in constructor - determine it dynamically on each request - this.baseUrl = baseUrl || ""; - + constructor() { // Try to load token synchronously if (typeof window !== "undefined") { this.token = localStorage.getItem(AUTH_TOKEN_KEY); @@ -172,11 +154,7 @@ class ApiClient { } } - // Get the base URL dynamically to support switching between localhost and IP private getBaseUrl(): string { - if (this.baseUrl) { - return this.baseUrl; - } return getApiBaseUrl(); } diff --git a/frontend/tests/e2e/global.setup.ts b/frontend/tests/e2e/global.setup.ts index eae59d1..493d64e 100644 --- a/frontend/tests/e2e/global.setup.ts +++ b/frontend/tests/e2e/global.setup.ts @@ -19,7 +19,7 @@ async function globalSetup(): Promise { if (!username || !password) { throw new Error( "E2E test user credentials not set.\n" + - "Set KIMA_TEST_USERNAME and KIMA_TEST_PASSWORD before running E2E tests.\n" + + "Set KIMA_TEST_USERNAME and KIMA_TEST_PASSWORD in .env.test or export them before running E2E tests.\n" + "To create a test user, run: bash scripts/create-e2e-user.sh" ); } diff --git a/frontend/tests/e2e/security.spec.ts b/frontend/tests/e2e/security.spec.ts index aeabf93..ed8a3aa 100644 --- a/frontend/tests/e2e/security.spec.ts +++ b/frontend/tests/e2e/security.spec.ts @@ -295,13 +295,14 @@ test.describe("Security", () => { }); expect([400, 422]).toContain(createRes.status()); - // Count must be unchanged + // Count must not have increased -- parallel tests may delete playlists + // concurrently, so we only assert the invalid request didn't create anything. const afterRes = await page.request.get("/api/playlists", { headers: { Authorization: `Bearer ${token}` }, }); const after = await afterRes.json() as unknown[]; const afterCount = Array.isArray(after) ? after.length : 0; - expect(afterCount).toBe(beforeCount); + expect(afterCount).not.toBeGreaterThan(beforeCount); }); }); }); diff --git a/frontend/tests/e2e/vibe.spec.ts b/frontend/tests/e2e/vibe.spec.ts index db50532..3531c36 100644 --- a/frontend/tests/e2e/vibe.spec.ts +++ b/frontend/tests/e2e/vibe.spec.ts @@ -56,21 +56,16 @@ test.describe("Vibe", () => { // ---- Page load ---------------------------------------------------------- - test("vibe page renders canvas or no-data state", async ({ page }) => { + test("vibe page renders map or no-data state", async ({ page }) => { await page.goto("/vibe", { waitUntil: "domcontentloaded" }); - // Either a canvas (map rendered) or the no-data placeholder must appear - const canvas = page.locator("canvas"); - const noData = page.locator("text=/No tracks with vibe|Computing music map/i"); + // Wait for loading to settle: either the track count appears (map loaded with data) + // or the no-data placeholder appears (library has no vibe embeddings yet). + // "Computing music map" is a transient loading state -- do not assert on it. + const trackCount = page.locator("text=/ tracks$/"); + const noData = page.locator("text=/No tracks with vibe/i"); - await Promise.race([ - canvas.waitFor({ timeout: 35_000 }), - noData.waitFor({ timeout: 35_000 }), - ]); - - const hasCanvas = (await canvas.count()) > 0; - const hasNoData = (await noData.count()) > 0; - expect(hasCanvas || hasNoData).toBe(true); + await expect(trackCount.or(noData)).toBeVisible({ timeout: 35_000 }); }); test("toolbar buttons are present when map loads", async ({ page }) => { diff --git a/scripts/create-e2e-user.sh b/scripts/create-e2e-user.sh index 5745085..61d8f08 100755 --- a/scripts/create-e2e-user.sh +++ b/scripts/create-e2e-user.sh @@ -17,6 +17,12 @@ CONTAINER="${KIMA_CONTAINER:-kima-test}" TEST_USER="${KIMA_TEST_USERNAME:-kima_e2e}" TEST_PASS="${KIMA_TEST_PASSWORD:-$(openssl rand -hex 20)}" +# Validate username to prevent SQL injection via the heredoc +if [[ ! "${TEST_USER}" =~ ^[a-zA-Z0-9_]{3,32}$ ]]; then + echo "[e2e setup] ERROR: KIMA_TEST_USERNAME must be 3-32 alphanumeric/underscore characters" >&2 + exit 1 +fi + echo "[e2e setup] Creating test user '${TEST_USER}' in container '${CONTAINER}'..." # Generate bcrypt hash inside the container where bcrypt is installed. @@ -48,7 +54,3 @@ ENDSQL ' echo "[e2e setup] Test user '${TEST_USER}' ready." -echo "" -echo "Set these env vars before running Playwright:" -echo " export KIMA_TEST_USERNAME=${TEST_USER}" -echo " export KIMA_TEST_PASSWORD=${TEST_PASS}"