diff --git a/4-Infrastructure/k3s-flake/tests/auth-state.json b/4-Infrastructure/k3s-flake/tests/auth-state.json new file mode 100644 index 00000000..05a83878 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/auth-state.json @@ -0,0 +1,35 @@ +{ + "cookies": [ + { + "name": "authentik_device", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1MTEwOWZkOTQ2M2U2MTY4MDdkOTczYmE3MDJiNDcyOTBlZjM5YmY4ZDNjNTc2ZjE1NDRjOTQzMWM4NzVjMjZmIiwiZXhwIjoxNzgyODM3OTg1LjczMzM4fQ.PLMAf2WTZjlOrlZAbg0R4wxK_l5qP5uCMedjZQRbx5E", + "domain": "auth.researchstack.info", + "path": "/", + "expires": 1782837985.854036, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "authentik_csrf", + "value": "Yar6rSBrSFyTJPFDtLncZFwXVxf6mTYX", + "domain": "auth.researchstack.info", + "path": "/", + "expires": 1811695585.854084, + "httpOnly": false, + "secure": true, + "sameSite": "Lax" + }, + { + "name": "authentik_session", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIwbGJ1c3FpZWZzdWQzMGdhMGQ4bHpmZTM1aWhnMnhyayIsImlzcyI6ImF1dGhlbnRpayIsInN1YiI6IjUxMTA5ZmQ5NDYzZTYxNjgwN2Q5NzNiYTcwMmI0NzI5MGVmMzliZjhkM2M1NzZmMTU0NGM5NDMxYzg3NWMyNmYiLCJhdXRoZW50aWNhdGVkIjp0cnVlLCJhY3IiOiJnb2F1dGhlbnRpay5pby9jb3JlL2RlZmF1bHQifQ.FEg6Gjx1yLhSE-qoHpZj-gy7Ypk3LZ4YWq9ykM1NX8k", + "domain": "auth.researchstack.info", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": true, + "sameSite": "None" + } + ], + "origins": [] +} \ No newline at end of file diff --git a/4-Infrastructure/k3s-flake/tests/authentik-admin-home.png b/4-Infrastructure/k3s-flake/tests/authentik-admin-home.png new file mode 100644 index 00000000..3039c101 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/authentik-admin-home.png differ diff --git a/4-Infrastructure/k3s-flake/tests/authentik-login-step1.png b/4-Infrastructure/k3s-flake/tests/authentik-login-step1.png new file mode 100644 index 00000000..6a562c68 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/authentik-login-step1.png differ diff --git a/4-Infrastructure/k3s-flake/tests/authentik-login-step2.png b/4-Infrastructure/k3s-flake/tests/authentik-login-step2.png new file mode 100644 index 00000000..5c71c9a9 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/authentik-login-step2.png differ diff --git a/4-Infrastructure/k3s-flake/tests/authentik-outpost-edit-clicked.png b/4-Infrastructure/k3s-flake/tests/authentik-outpost-edit-clicked.png new file mode 100644 index 00000000..c8f65f88 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/authentik-outpost-edit-clicked.png differ diff --git a/4-Infrastructure/k3s-flake/tests/authentik-outpost-updated.png b/4-Infrastructure/k3s-flake/tests/authentik-outpost-updated.png new file mode 100644 index 00000000..22e0c6cb Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/authentik-outpost-updated.png differ diff --git a/4-Infrastructure/k3s-flake/tests/authentik-outposts-page.png b/4-Infrastructure/k3s-flake/tests/authentik-outposts-page.png new file mode 100644 index 00000000..9c8851ce Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/authentik-outposts-page.png differ diff --git a/4-Infrastructure/k3s-flake/tests/authentik-sidebar-expanded.png b/4-Infrastructure/k3s-flake/tests/authentik-sidebar-expanded.png new file mode 100644 index 00000000..3039c101 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/authentik-sidebar-expanded.png differ diff --git a/4-Infrastructure/k3s-flake/tests/check-outpost.spec.ts b/4-Infrastructure/k3s-flake/tests/check-outpost.spec.ts new file mode 100644 index 00000000..4443986a --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/check-outpost.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; + +test('Check and Configure Authentik Outpost', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + console.log('Navigating to Authentik admin outposts...'); + await page.goto('https://auth.researchstack.info/if/admin/#/apps/outposts'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Robust login detection: check if "Log in" button or username field is visible + const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first(); + if (await loginInput.isVisible()) { + console.log('Login form detected. Logging in...'); + await loginInput.fill('akadmin'); + await page.screenshot({ path: 'authentik-login-step1.png' }); + + const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first(); + await continueBtn.click(); + await page.waitForTimeout(2000); + + const passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + await passwordInput.fill('authentik'); + await page.screenshot({ path: 'authentik-login-step2.png' }); + + await continueBtn.click(); + await page.waitForTimeout(6000); + + console.log('Logged in. Current URL:', page.url()); + } + + // Double check we are on the outposts page + if (!page.url().includes('/apps/outposts')) { + console.log('Navigating to outposts page directly...'); + await page.goto('https://auth.researchstack.info/if/admin/#/apps/outposts'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + } + + await page.screenshot({ path: 'authentik-outposts-page.png' }); + console.log('Outposts URL:', page.url()); + + // Wait, the page is built using web components, so finding elements might require waiting or shadow DOM piercing + // Let's dump all text on the page to see if "authentik Embedded Outpost" is visible + const pageText = await page.evaluate(() => document.body.innerText); + console.log('Outposts Page text:', pageText.slice(0, 1500)); + + // Let's find the edit button. In Authentik, it is often a button inside a table row. + // We can search for edit buttons and click the first one if there is only one outpost, or find one near "Embedded Outpost" + // Let's locate the edit button and click it + console.log('Looking for Edit button...'); + const editBtn = page.locator('ak-table-row:has-text("authentik Embedded Outpost") button[title*="Edit"], button:has-text("Edit"), button[aria-label*="Edit"]').first(); + if (await editBtn.isVisible()) { + console.log('Edit button is visible. Clicking...'); + await editBtn.click(); + await page.waitForTimeout(3000); + await page.screenshot({ path: 'authentik-outpost-edit-modal.png' }); + + // Let's see if we can find selected apps + const modalText = await page.evaluate(() => document.querySelector('div[role="dialog"], ak-modal-dialog')?.innerText || 'No modal'); + console.log('Modal text:', modalText); + } else { + console.log('Edit button is NOT visible directly. Let us click on any edit button on the page.'); + const anyEditBtn = page.locator('button[title*="Edit"], button:has-text("Edit")').first(); + if (await anyEditBtn.isVisible()) { + await anyEditBtn.click(); + await page.waitForTimeout(3000); + await page.screenshot({ path: 'authentik-outpost-edit-modal.png' }); + } else { + console.log('Could not find any Edit button at all.'); + } + } + + await context.storageState({ path: 'auth-state.json' }); + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/check-outposts-api.spec.ts b/4-Infrastructure/k3s-flake/tests/check-outposts-api.spec.ts new file mode 100644 index 00000000..b72cbaaf --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/check-outposts-api.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; + +test('Query Authentik Outposts API', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + console.log('Navigating to Authentik admin home...'); + await page.goto('https://auth.researchstack.info/if/admin/#/home'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Login + const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first(); + if (await loginInput.isVisible()) { + console.log('Logging in...'); + await loginInput.fill('akadmin'); + const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first(); + await continueBtn.click(); + await page.waitForTimeout(2000); + const passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + await passwordInput.fill('authentik'); + await continueBtn.click(); + await page.waitForTimeout(6000); + } + + // Now run fetch in the browser context to query the outpost instances API + const outposts = await page.evaluate(async () => { + try { + const res = await fetch('/api/v3/outposts/instances/'); + return await res.json(); + } catch (err) { + return { error: (err as Error).message }; + } + }); + + console.log('Outpost instances response:', JSON.stringify(outposts, null, 2)); + + // Also query applications to see what apps are configured + const applications = await page.evaluate(async () => { + try { + const res = await fetch('/api/v3/core/applications/'); + return await res.json(); + } catch (err) { + return { error: (err as Error).message }; + } + }); + + console.log('Applications response:', JSON.stringify(applications, null, 2)); + + // Also query providers to see what providers are configured + const providers = await page.evaluate(async () => { + try { + const res = await fetch('/api/v3/providers/all/'); + return await res.json(); + } catch (err) { + return { error: (err as Error).message }; + } + }); + + console.log('Providers response:', JSON.stringify(providers, null, 2)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/complete-all.spec.ts b/4-Infrastructure/k3s-flake/tests/complete-all.spec.ts new file mode 100644 index 00000000..d656a3a4 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/complete-all.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '@playwright/test'; + +test('Complete Jellyfin setup + OIDC plugin', async ({ browser }) => { + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + // Step 1: Complete Jellyfin Setup Wizard + console.log('=== Starting Jellyfin Setup Wizard ==='); + await page.goto('https://media.researchstack.info/web/#/wizard/start', { waitUntil: 'networkidle' }); + await page.waitForTimeout(3000); + + // Step 1: Language - click Next + console.log('Step 1: Language'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 2: Create admin account + console.log('Step 2: Create Admin'); + await page.locator('input#txtUsername:visible').fill('admin'); + await page.locator('input#txtManualPassword:visible').fill('jellyfin-admin'); + await page.locator('input#txtPasswordConfirm:visible').fill('jellyfin-admin'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 3: Media Libraries - skip + console.log('Step 3: Libraries'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 4: Metadata Language + console.log('Step 4: Metadata'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 5: Remote Access + console.log('Step 5: Remote Access'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 6: Finish + console.log('Step 6: Finish'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(5000); + + console.log('Wizard complete at:', page.url()); + + // Step 2: Login + console.log('=== Logging in ==='); + if (page.url().includes('/login') || page.url().includes('/web')) { + await page.goto('https://media.researchstack.info/web/#/login.html', { waitUntil: 'networkidle' }); + await page.waitForTimeout(3000); + const username = page.locator('input#txtUsername:visible'); + const password = page.locator('input#txtPassword:visible'); + if (await username.isVisible()) { + await username.fill('admin'); + await password.fill('jellyfin-admin'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(5000); + } + } + + // Step 3: Navigate to Dashboard -> Plugins + console.log('=== Installing OIDC Plugin ==='); + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins', { waitUntil: 'networkidle' }); + await page.waitForTimeout(5000); + + // Click Catalog tab + await page.locator('a[href="#/dashboard/plugins/catalog"]:visible').first().click(); + await page.waitForTimeout(3000); + + // Search for OIDC plugin + console.log('Searching for OIDC plugin...'); + const searchBox = page.locator('input[type="text"]:visible').first(); + if (await searchBox.isVisible()) { + await searchBox.fill('OIDC'); + await page.waitForTimeout(3000); + } + + // Install the OIDC plugin + console.log('Looking for install button...'); + const installButtons = page.locator('button:has-text("Install"):visible'); + const count = await installButtons.count(); + console.log(`Found ${count} install buttons`); + if (count > 0) { + await installButtons.first().click(); + await page.waitForTimeout(2000); + } + + // This might trigger a restart notification + await page.waitForTimeout(2000); + + console.log('Plugin installation initiated. Jellyfin will restart.'); + + // Wait for Jellyfin to restart + console.log('Waiting for Jellyfin restart...'); + await page.waitForTimeout(15000); + + // Try to navigate back and check if plugin is installed + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins', { waitUntil: 'networkidle' }); + await page.waitForTimeout(5000); + + console.log('Current URL:', page.url()); + console.log('=== DONE ==='); +}); diff --git a/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard-v2.spec.ts b/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard-v2.spec.ts new file mode 100644 index 00000000..808526a5 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard-v2.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +test('Complete Jellyfin setup wizard step-by-step', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + console.log('URL at start:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-start.png' }); + + // If we are on the login page instead of the wizard, go to wizard + if (page.url().includes('login')) { + console.log('Redirecting to wizard start manually...'); + await page.goto('https://media.researchstack.info/web/#/wizard/start'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + } + + // Step 1: Language + console.log('Step 1 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-step1.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 2: Create User + console.log('Step 2 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-step2.png' }); + + const usernameInput = page.locator('input#txtUsername:visible'); + const passwordInput = page.locator('input#txtManualPassword:visible'); + const confirmInput = page.locator('input#txtPasswordConfirm:visible'); + + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await confirmInput.fill('RY03KhsFez73K5va2uUb'); + + await page.screenshot({ path: 'jellyfin-wizard-v2-step2-filled.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 3: Libraries + console.log('Step 3 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-step3.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 4: Metadata Language + console.log('Step 4 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-step4.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 5: Remote Access + console.log('Step 5 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-step5.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 6: Finish + console.log('Step 6 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-step6.png' }); + + const finishBtn = page.locator('button:has-text("Finish"):visible, .button-submit:visible').first(); + await finishBtn.click(); + await page.waitForTimeout(8000); + + console.log('After wizard URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-finished.png' }); + + // Log in + const userLoginInput = page.locator('input#txtManualName:visible, input[type="text"]:visible').first(); + const passLoginInput = page.locator('input#txtManualPassword:visible, input[type="password"]:visible').first(); + + if (await userLoginInput.isVisible()) { + console.log('Logging in manually to verify...'); + await userLoginInput.fill('admin'); + await passLoginInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"]:visible, .button-submit:visible').first().click(); + await page.waitForTimeout(6000); + console.log('Logged in home URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v2-home.png' }); + } + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard-v3.spec.ts b/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard-v3.spec.ts new file mode 100644 index 00000000..b4b2dc83 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard-v3.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +test('Complete Jellyfin setup wizard with forced clicks', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + console.log('URL at start:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-start.png' }); + + // If we are on the login page instead of the wizard, go to wizard + if (page.url().includes('login')) { + console.log('Redirecting to wizard start manually...'); + await page.goto('https://media.researchstack.info/web/#/wizard/start'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + } + + // Step 1: Language + console.log('Step 1 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-step1.png' }); + await page.locator('.button-submit:visible').first().click({ force: true }); + await page.waitForTimeout(4000); + + // Step 2: Create User + console.log('Step 2 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-step2.png' }); + + const usernameInput = page.locator('input#txtUsername:visible'); + const passwordInput = page.locator('input#txtManualPassword:visible'); + const confirmInput = page.locator('input#txtPasswordConfirm:visible'); + + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await confirmInput.fill('RY03KhsFez73K5va2uUb'); + + await page.screenshot({ path: 'jellyfin-wizard-v3-step2-filled.png' }); + await page.locator('.button-submit:visible').first().click({ force: true }); + await page.waitForTimeout(4000); + + // Step 3: Libraries + console.log('Step 3 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-step3.png' }); + await page.locator('.button-submit:visible').first().click({ force: true }); + await page.waitForTimeout(4000); + + // Step 4: Metadata Language + console.log('Step 4 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-step4.png' }); + await page.locator('.button-submit:visible').first().click({ force: true }); + await page.waitForTimeout(4000); + + // Step 5: Remote Access + console.log('Step 5 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-step5.png' }); + await page.locator('.button-submit:visible').first().click({ force: true }); + await page.waitForTimeout(4000); + + // Step 6: Finish + console.log('Step 6 URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-step6.png' }); + + const finishBtn = page.locator('button:has-text("Finish"):visible, .button-submit:visible').first(); + await finishBtn.click({ force: true }); + await page.waitForTimeout(8000); + + console.log('After wizard URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-finished.png' }); + + // Log in + const userLoginInput = page.locator('input#txtManualName:visible, input[type="text"]:visible').first(); + const passLoginInput = page.locator('input#txtManualPassword:visible, input[type="password"]:visible').first(); + + if (await userLoginInput.isVisible()) { + console.log('Logging in manually to verify...'); + await userLoginInput.fill('admin'); + await passLoginInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"]:visible, .button-submit:visible').first().click({ force: true }); + await page.waitForTimeout(6000); + console.log('Logged in home URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-v3-home.png' }); + } + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard.spec.ts b/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard.spec.ts new file mode 100644 index 00000000..1cb89772 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/complete-jellyfin-wizard.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; + +test('Complete Jellyfin setup wizard with class selectors', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin wizard start...'); + await page.goto('https://media.researchstack.info/web/#/wizard/start'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Step 1: Display language (default is English) + console.log('Wizard Step 1: Preferred display language...'); + await page.screenshot({ path: 'jellyfin-wizard-step1.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 2: Create Admin account + console.log('Wizard Step 2: Creating Admin account...'); + await page.screenshot({ path: 'jellyfin-wizard-step2.png' }); + + const usernameInput = page.locator('input#txtUsername:visible'); + const passwordInput = page.locator('input#txtManualPassword:visible'); + const confirmInput = page.locator('input#txtPasswordConfirm:visible'); + + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await confirmInput.fill('RY03KhsFez73K5va2uUb'); + + await page.screenshot({ path: 'jellyfin-wizard-step2-filled.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 3: Set up media libraries + console.log('Wizard Step 3: Media Libraries...'); + await page.screenshot({ path: 'jellyfin-wizard-step3.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 4: Preferred Metadata Language + console.log('Wizard Step 4: Metadata Language...'); + await page.screenshot({ path: 'jellyfin-wizard-step4.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 5: Configure Remote Access + console.log('Wizard Step 5: Remote Access...'); + await page.screenshot({ path: 'jellyfin-wizard-step5.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(4000); + + // Step 6: Finish + console.log('Wizard Step 6: Finish...'); + await page.screenshot({ path: 'jellyfin-wizard-step6.png' }); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(8000); + + console.log('Wizard complete. Current URL:', page.url()); + await page.screenshot({ path: 'jellyfin-after-wizard.png' }); + + // Log in to Jellyfin + console.log('Logging in to Jellyfin with new admin account...'); + const userLoginInput = page.locator('input#txtManualName:visible, input[type="text"]:visible').first(); + const passLoginInput = page.locator('input#txtManualPassword:visible, input[type="password"]:visible').first(); + + if (await userLoginInput.isVisible()) { + await userLoginInput.fill('admin'); + await passLoginInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"]:visible, .button-submit:visible').first().click(); + await page.waitForTimeout(6000); + } + + console.log('Home dashboard URL:', page.url()); + await page.screenshot({ path: 'jellyfin-home-dashboard.png' }); + const textContent = await page.evaluate(() => document.body.innerText); + console.log('Jellyfin main page text:', textContent.slice(0, 1000)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/configure-jellyfin.spec.ts b/4-Infrastructure/k3s-flake/tests/configure-jellyfin.spec.ts new file mode 100644 index 00000000..5322e742 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/configure-jellyfin.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from '@playwright/test'; + +test('Log in to Jellyfin and configure OIDC plugin', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + console.log('Current URL:', page.url()); + + // Fill Jellyfin login details + const usernameInput = page.locator('input[type="text"], input#txtManualName'); + const passwordInput = page.locator('input[type="password"], input#txtManualPassword'); + + if (await usernameInput.isVisible()) { + console.log('Logging into Jellyfin manually...'); + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"], button.button-submit').first().click(); + await page.waitForTimeout(6000); + } + + console.log('Logged in. URL:', page.url()); + await page.screenshot({ path: 'jellyfin-home.png' }); + + // Navigate directly to Plugins page + console.log('Navigating directly to Plugins page...'); + await page.goto('https://media.researchstack.info/web/#/plugins.html'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(5000); + console.log('Plugins URL:', page.url()); + await page.screenshot({ path: 'jellyfin-plugins.png' }); + + const textContent = await page.evaluate(() => document.body.innerText); + console.log('Plugins page text:', textContent.slice(0, 1000)); + + if (textContent.includes('OpenID Connect')) { + console.log('OIDC Plugin is already installed!'); + } else { + console.log('OIDC Plugin not found in installed list. Navigating to Catalog...'); + // Click on Catalog tab + const catalogTab = page.locator('button:has-text("Catalog"), a:has-text("Catalog"), [role="tab"]:has-text("Catalog")').first(); + await catalogTab.click(); + await page.waitForTimeout(5000); + await page.screenshot({ path: 'jellyfin-catalog.png' }); + + // Search for OIDC or look at the list + console.log('Searching for OpenID Connect in catalog...'); + const oidcLink = page.locator('a:has-text("OpenID Connect"), [role="button"]:has-text("OpenID Connect")').first(); + if (await oidcLink.isVisible()) { + await oidcLink.click(); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugin-details.png' }); + + // Click Install + const installBtn = page.locator('button:has-text("Install"), button.button-submit').first(); + await installBtn.click(); + await page.waitForTimeout(4000); + + // Handle OK button in modal dialog if any + const okBtn = page.locator('button:has-text("OK")').first(); + if (await okBtn.isVisible()) { + await okBtn.click(); + await page.waitForTimeout(2000); + } + console.log('OIDC Plugin installation triggered. Need to restart pod!'); + + // Let's exit the test and let the runner restart the pod + await context.close(); + return; + } else { + console.log('Could not find OpenID Connect in catalog.'); + await context.close(); + throw new Error('OIDC Plugin not found in catalog'); + } + } + + // If we reach here, OIDC plugin is installed. Let's configure it. + console.log('Navigating to OIDC configuration page...'); + // Click on "OpenID Connect" plugin row/link in the installed list + const oidcConfigLink = page.locator('a:has-text("OpenID Connect"), [role="button"]:has-text("OpenID Connect")').first(); + await oidcConfigLink.click(); + await page.waitForTimeout(5000); + console.log('OIDC Configuration URL:', page.url()); + await page.screenshot({ path: 'jellyfin-oidc-config-initial.png' }); + + // In the OpenID Connect settings form, fill in values: + // Enabled checkbox, Client ID, Client Secret, OIDC Authority + // Let's inspect the input fields + console.log('Filling OIDC config fields...'); + + // Let's log all input fields on the page to see what's there + const inputs = await page.evaluate(() => { + return Array.from(document.querySelectorAll('input, select, textarea')).map(el => ({ + id: el.id, + name: el.getAttribute('name'), + type: el.getAttribute('type'), + value: (el as HTMLInputElement).value, + label: el.labels?.[0]?.innerText || '' + })); + }); + console.log('Found form inputs:', inputs); + + // Enabled checkbox: usually has an id or label containing "Enable" or "Enabled" + const enabledCheckbox = page.locator('input[type="checkbox"], input#chkEnableOpenId').first(); + const isChecked = await enabledCheckbox.isChecked(); + if (!isChecked) { + await enabledCheckbox.click(); + } + + // OIDC Authority / Issuer + const authorityInput = page.locator('input[label*="Authority"], input[id*="Authority"], input[name*="Authority"], input[placeholder*="Authority"]').first(); + if (await authorityInput.isVisible()) { + await authorityInput.fill('https://auth.researchstack.info/application/o/jellyfin/'); + } else { + // Try by index or other attributes + const firstTextInput = page.locator('input[type="text"]').first(); + await firstTextInput.fill('https://auth.researchstack.info/application/o/jellyfin/'); + } + + // Client ID + const clientIdInput = page.locator('input[label*="Client ID"], input[id*="ClientId"], input[name*="ClientId"]').first(); + if (await clientIdInput.isVisible()) { + await clientIdInput.fill('sso-jellyfin'); + } + + // Client Secret + const clientSecretInput = page.locator('input[label*="Secret"], input[id*="Secret"], input[name*="Secret"]').first(); + if (await clientSecretInput.isVisible()) { + await clientSecretInput.fill('fc96e87934f544bef43e5466c929baa73b681c4e5d0aa9fc5cafe6e6a36e2e49'); + } + + await page.screenshot({ path: 'jellyfin-oidc-config-filled.png' }); + + // Click Save + const saveBtn = page.locator('button[type="submit"], button:has-text("Save"), button.button-submit').first(); + await saveBtn.click(); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-oidc-config-saved.png' }); + console.log('OIDC Configuration saved.'); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/configure.spec.ts b/4-Infrastructure/k3s-flake/tests/configure.spec.ts new file mode 100644 index 00000000..56bf56c4 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/configure.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; + +test('Configure Authentik and check Uptime Kuma', async ({ browser }) => { + // Create first context and log in to Authentik + const context = await browser.newContext(); + const page = await context.newPage(); + + console.log('Navigating to Authentik login page...'); + await page.goto('https://auth.researchstack.info/flows/-/default/authentication/'); + await page.waitForLoadState('networkidle'); + + // Fill username + console.log('Logging in to Authentik...'); + await page.locator('input[name="uid"], input[id*="uid"], input[type="text"]').first().fill('akadmin'); + await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click(); + await page.waitForTimeout(1000); + + // Fill password + await page.locator('input[name="password"], input[id*="password"], input[type="password"]').first().fill('authentik'); + await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click(); + + // Wait for redirect to complete + await page.waitForTimeout(5000); + console.log('Logged in to Authentik. URL:', page.url()); + + // Save storage state to reuse cookies + console.log('Saving authentication state to auth-state.json...'); + await context.storageState({ path: 'auth-state.json' }); + await context.close(); + + // Create a new context with the saved storage state + console.log('Launching new context with saved auth state...'); + const newContext = await browser.newContext({ storageState: 'auth-state.json' }); + const newPage = await newContext.newPage(); + + // Go to Uptime Kuma + console.log('Navigating to Uptime Kuma...'); + await newPage.goto('https://uptime.researchstack.info/'); + await newPage.waitForLoadState('networkidle'); + await newPage.waitForTimeout(4000); + + // Check if we are on the consent page + if (newPage.url().includes('default-provider-authorization-explicit-consent')) { + console.log('On consent page. Clicking Continue...'); + const continueBtn = newPage.locator('button:has-text("Continue"), input[type="submit"]'); + await continueBtn.first().click(); + await newPage.waitForTimeout(5000); + } + + await newPage.screenshot({ path: 'uptime-kuma-dashboard.png' }); + console.log('Uptime Kuma dashboard screenshot saved.'); + console.log('Uptime Kuma URL:', newPage.url()); + console.log('Uptime Kuma Title:', await newPage.title()); + + const content = await newPage.evaluate(() => document.body.innerText); + console.log('Uptime Kuma text:', content.slice(0, 1000)); + + await newContext.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/debug-jellyfin.spec.ts b/4-Infrastructure/k3s-flake/tests/debug-jellyfin.spec.ts new file mode 100644 index 00000000..4d9b9272 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/debug-jellyfin.spec.ts @@ -0,0 +1,21 @@ +import { test } from '@playwright/test'; +test('Debug Jellyfin wizard', async ({ page }) => { + test.setTimeout(60000); + await page.goto('https://media.researchstack.info/web/#/wizard/start.html', { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(5000); + await page.screenshot({ path: '/tmp/jf-wizard-debug.png', fullPage: true }); + + // Dump all buttons + const buttons = page.locator('button'); + const count = await buttons.count(); + console.log(`Found ${count} buttons`); + for (let i = 0; i < count; i++) { + const text = await buttons.nth(i).textContent(); + const visible = await buttons.nth(i).isVisible(); + console.log(` Button ${i}: text="${text?.trim()}" visible=${visible}`); + } + + // Dump the page title + console.log('Title:', await page.title()); + console.log('URL:', page.url()); +}); diff --git a/4-Infrastructure/k3s-flake/tests/dump-header.spec.ts b/4-Infrastructure/k3s-flake/tests/dump-header.spec.ts new file mode 100644 index 00000000..db996c7b --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/dump-header.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test('Dump Jellyfin Home page HTML', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Login + const usernameInput = page.locator('input[type="text"], input#txtManualName'); + const passwordInput = page.locator('input[type="password"], input#txtManualPassword'); + + if (await usernameInput.isVisible()) { + console.log('Logging in...'); + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"], button.button-submit').first().click(); + await page.waitForTimeout(6000); + } + + // Dump HTML of body + const bodyHtml = await page.evaluate(() => document.body.innerHTML); + console.log('Body HTML length:', bodyHtml.length); + // Log a subset of body HTML where headers or buttons are + console.log('Header/Buttons HTML:', bodyHtml.slice(0, 3000)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/dump-jellyfin-wizard-html.spec.ts b/4-Infrastructure/k3s-flake/tests/dump-jellyfin-wizard-html.spec.ts new file mode 100644 index 00000000..40c424b4 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/dump-jellyfin-wizard-html.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +test('Dump Jellyfin wizard Step 2 HTML', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin wizard start...'); + await page.goto('https://media.researchstack.info/web/#/wizard/start'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Click Next to go to Step 2 + await page.locator('button:has-text("Next")').first().click(); + await page.waitForTimeout(4000); + + console.log('At Step 2. URL:', page.url()); + + // Dump form HTML + const formHtml = await page.evaluate(() => { + const form = document.querySelector('form'); + return form ? form.innerHTML : 'Form not found'; + }); + console.log('--- Form HTML Start ---'); + console.log(formHtml); + console.log('--- Form HTML End ---'); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/dump-plugins-html.spec.ts b/4-Infrastructure/k3s-flake/tests/dump-plugins-html.spec.ts new file mode 100644 index 00000000..85c71562 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/dump-plugins-html.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +test('Dump plugins page HTML', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating directly to plugins page...'); + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(5000); + + const bodyHtml = await page.evaluate(() => document.body.innerHTML); + console.log('Plugins Body HTML length:', bodyHtml.length); + // Log the first 4000 characters and look for tab buttons + console.log('Body HTML:', bodyHtml.slice(0, 4000)); + + // Let's also look for text "Available" in the HTML string and show surrounding content + const idx = bodyHtml.indexOf('Available'); + if (idx !== -1) { + console.log('Found "Available" at index:', idx); + console.log('Context:', bodyHtml.slice(Math.max(0, idx - 200), idx + 200)); + } else { + console.log('"Available" text not found in raw innerHTML!'); + } + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/dump-step2.spec.ts b/4-Infrastructure/k3s-flake/tests/dump-step2.spec.ts new file mode 100644 index 00000000..3e4dd7be --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/dump-step2.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; + +test('Dump Step 2 inputs', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to wizard start...'); + await page.goto('https://media.researchstack.info/web/#/wizard/start'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Click Next on step 1 + console.log('Clicking Next on step 1...'); + await page.locator('button[type="submit"], button:has-text("Next")').first().click(); + + // Wait for URL to change to /wizard/user + console.log('Waiting for wizard/user page...'); + await page.waitForURL('**/wizard/user**'); + await page.waitForTimeout(4000); + + console.log('Current URL:', page.url()); + + // Print all input tags on the page + const inputs = await page.evaluate(() => { + return Array.from(document.querySelectorAll('input, select, button')).map(el => ({ + tagName: el.tagName, + id: el.id, + type: el.getAttribute('type'), + className: el.className, + innerText: (el as HTMLElement).innerText || el.getAttribute('value') + })); + }); + console.log('Inputs found:', JSON.stringify(inputs, null, 2)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/dump-step3.spec.ts b/4-Infrastructure/k3s-flake/tests/dump-step3.spec.ts new file mode 100644 index 00000000..f8fc65ae --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/dump-step3.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; + +test('Dump Step 3 inputs', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to wizard start...'); + await page.goto('https://media.researchstack.info/web/#/wizard/start'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Click Next on step 1 + console.log('Clicking Next on step 1...'); + await page.locator('button[type="submit"]:visible, button:has-text("Next"):visible').click(); + await page.waitForTimeout(3000); + + // Fill Step 2 + console.log('Filling Step 2...'); + await page.locator('input#txtUsername:visible').fill('admin'); + await page.locator('input#txtManualPassword:visible').fill('RY03KhsFez73K5va2uUb'); + await page.locator('input#txtPasswordConfirm:visible').fill('RY03KhsFez73K5va2uUb'); + + // Click Next on step 2 + console.log('Clicking Next on step 2...'); + await page.locator('button[type="submit"]:visible, button:has-text("Next"):visible').click(); + await page.waitForTimeout(3000); + + console.log('Waiting for wizard/library page...'); + await page.waitForURL('**/wizard/library**'); + await page.waitForTimeout(4000); + + console.log('Current URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-step3-debug.png' }); + + // Print all visible input/button tags on the page + const inputs = await page.evaluate(() => { + return Array.from(document.querySelectorAll('input, select, button')).map(el => ({ + tagName: el.tagName, + id: el.id, + type: el.getAttribute('type'), + className: el.className, + innerText: (el as HTMLElement).innerText || el.getAttribute('value'), + isVisible: (el as HTMLElement).offsetWidth > 0 && (el as HTMLElement).offsetHeight > 0 + })); + }); + console.log('Inputs found in Step 3:', JSON.stringify(inputs, null, 2)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/dump-step5.spec.ts b/4-Infrastructure/k3s-flake/tests/dump-step5.spec.ts new file mode 100644 index 00000000..2f0c3ba4 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/dump-step5.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; + +test('Dump Step 5 inputs', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to wizard start...'); + await page.goto('https://media.researchstack.info/web/#/wizard/start'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + // Step 1 -> Step 2 + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 2 -> Step 3 + await page.locator('input#txtUsername:visible').fill('admin'); + await page.locator('input#txtManualPassword:visible').fill('RY03KhsFez73K5va2uUb'); + await page.locator('input#txtPasswordConfirm:visible').fill('RY03KhsFez73K5va2uUb'); + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 3 -> Step 4 + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + // Step 4 -> Step 5 + await page.locator('.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + console.log('Current URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard-step5-debug.png' }); + + // Print all visible input/button tags on the page + const inputs = await page.evaluate(() => { + return Array.from(document.querySelectorAll('input, select, button')).map(el => ({ + tagName: el.tagName, + id: el.id, + type: el.getAttribute('type'), + className: el.className, + innerText: (el as HTMLElement).innerText || el.getAttribute('value'), + isVisible: (el as HTMLElement).offsetWidth > 0 && (el as HTMLElement).offsetHeight > 0 + })); + }); + console.log('Inputs found in Step 5:', JSON.stringify(inputs, null, 2)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/dump-tab-elements.spec.ts b/4-Infrastructure/k3s-flake/tests/dump-tab-elements.spec.ts new file mode 100644 index 00000000..228e1665 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/dump-tab-elements.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +test('Dump tab elements HTML', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + const elementsHtml = await page.evaluate(() => { + const results: string[] = []; + // Find all links, buttons, spans, divs with class + const selectors = ['a', 'button', 'span', 'div.tabButton', '.button-flat', '.emby-button']; + selectors.forEach(sel => { + document.querySelectorAll(sel).forEach(el => { + const text = el.textContent?.trim() || ''; + if (text === 'Available' || text === 'Installed' || text === 'All') { + results.push(`<${el.tagName.toLowerCase()} class="${el.className}" id="${el.id}" href="${el.getAttribute('href') || ''}" outer="${el.outerHTML.slice(0, 300)}">`); + } + }); + }); + return results; + }); + + console.log('Tab elements found:', elementsHtml); + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/explore-dashboard.spec.ts b/4-Infrastructure/k3s-flake/tests/explore-dashboard.spec.ts new file mode 100644 index 00000000..27355d1b --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/explore-dashboard.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from '@playwright/test'; + +test('Explore Jellyfin Dashboard and find Plugins', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin Home...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Login if needed + const usernameInput = page.locator('input[type="text"], input#txtManualName'); + const passwordInput = page.locator('input[type="password"], input#txtManualPassword'); + + if (await usernameInput.isVisible()) { + console.log('Logging in...'); + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"], button.button-submit').first().click(); + await page.waitForTimeout(6000); + } + + console.log('Navigating directly to Dashboard...'); + await page.goto('https://media.researchstack.info/web/#/dashboard'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(5000); + await page.screenshot({ path: 'jellyfin-dashboard-direct.png' }); + + // Get all links on this page + const links = await page.evaluate(() => { + return Array.from(document.querySelectorAll('a')).map(a => ({ + text: a.innerText.trim(), + href: a.getAttribute('href') + })); + }); + console.log('Links on Dashboard page:', links); + + // Let's search for "Plugins" link in the list or click on it + const pluginsLinkInfo = links.find(l => l.text.includes('Plugins') || (l.href && l.href.includes('plugins'))); + if (pluginsLinkInfo) { + console.log('Found Plugins link info:', pluginsLinkInfo); + // Click the actual element + await page.locator(`a[href="${pluginsLinkInfo.href}"], a:has-text("Plugins")`).first().click(); + await page.waitForTimeout(5000); + await page.screenshot({ path: 'jellyfin-dashboard-plugins-clicked.png' }); + + // Check if we are on the plugins page and print details + const currentUrl = page.url(); + console.log('Plugins Page URL after click:', currentUrl); + const textContent = await page.evaluate(() => document.body.innerText); + console.log('Plugins Page text:', textContent.slice(0, 1000)); + } else { + console.log('Could not find Plugins link on dashboard page'); + } + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/find-available-element.spec.ts b/4-Infrastructure/k3s-flake/tests/find-available-element.spec.ts new file mode 100644 index 00000000..845be385 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/find-available-element.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +test('Find Available element HTML', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Print HTML of elements containing "Available" + const elementsHtml = await page.evaluate(() => { + // Find all elements containing the text "Available" + const results: string[] = []; + const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); + let n; + while (n = walk.nextNode()) { + const el = n as HTMLElement; + if (el.textContent === 'Available' || el.getAttribute('class')?.includes('Available') || el.innerText === 'Available') { + results.push(`<${el.tagName.toLowerCase()} class="${el.className}" id="${el.id}" href="${el.getAttribute('href') || ''}">${el.outerHTML.slice(0, 300)}`); + } + } + return results; + }); + + console.log('Available elements found:', elementsHtml); + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/find-outposts-menu.spec.ts b/4-Infrastructure/k3s-flake/tests/find-outposts-menu.spec.ts new file mode 100644 index 00000000..0a9fc892 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/find-outposts-menu.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; + +test('Explore Authentik Sidebar and find Outposts', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + console.log('Navigating to Authentik admin home...'); + await page.goto('https://auth.researchstack.info/if/admin/#/home'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Login + const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first(); + if (await loginInput.isVisible()) { + console.log('Logging in...'); + await loginInput.fill('akadmin'); + const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first(); + await continueBtn.click(); + await page.waitForTimeout(2000); + const passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + await passwordInput.fill('authentik'); + await continueBtn.click(); + await page.waitForTimeout(6000); + } + + await page.screenshot({ path: 'authentik-admin-home.png' }); + + // Click on "Applications" on the sidebar + // Let's locate the navigation element. Authentik admin panel uses web components, so let's pierce the shadow DOM. + // We can click the text "Applications" or find the navigation item. + console.log('Clicking Applications sidebar group...'); + const appsGroup = page.locator('span:has-text("Applications"), a:has-text("Applications"), div:has-text("Applications")').first(); + await appsGroup.click(); + await page.waitForTimeout(2000); + await page.screenshot({ path: 'authentik-sidebar-expanded.png' }); + + // Let's print out all links on the page (including shadow DOM if possible) + const links = await page.evaluate(() => { + // Helper to recursively find elements including in shadow roots + function getElements(root: Element | ShadowRoot): string[] { + const results: string[] = []; + const anchors = root.querySelectorAll('a'); + anchors.forEach(a => { + results.push(`${a.textContent?.trim()} -> ${a.getAttribute('href')}`); + }); + const all = root.querySelectorAll('*'); + all.forEach(el => { + if (el.shadowRoot) { + results.push(...getElements(el.shadowRoot)); + } + }); + return results; + } + return getElements(document.body); + }); + + console.log('All links found:', links); + + // Let's find one that looks like outposts and navigate to it + const outpostLink = links.find(l => l.toLowerCase().includes('outpost')); + console.log('Outpost link found:', outpostLink); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/find-pills.spec.ts b/4-Infrastructure/k3s-flake/tests/find-pills.spec.ts new file mode 100644 index 00000000..d183ab7e --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/find-pills.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +test('Find tab pills HTML', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + const elements = await page.evaluate(() => { + // Find all elements that contain the word "Available" + const results: string[] = []; + const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); + let n; + while (n = walk.nextNode()) { + const el = n as HTMLElement; + // If it contains the exact text "Available" (no children or children text is just "Available") + if (el.textContent?.trim() === 'Available') { + results.push(`Tag: ${el.tagName.toLowerCase()}, Class: ${el.className}, ID: ${el.id}, OuterHTML: ${el.outerHTML.slice(0, 300)}`); + } + } + return results; + }); + + console.log('Found Available elements:', elements); + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/homarr-status.png b/4-Infrastructure/k3s-flake/tests/homarr-status.png new file mode 100644 index 00000000..0396e79b Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/homarr-status.png differ diff --git a/4-Infrastructure/k3s-flake/tests/homarr.spec.ts b/4-Infrastructure/k3s-flake/tests/homarr.spec.ts new file mode 100644 index 00000000..393199d0 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/homarr.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; + +test('Check Homarr Status', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Homarr...'); + await page.goto('https://www.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + if (page.url().includes('default-provider-authorization-explicit-consent')) { + await page.locator('button:has-text("Continue"), input[type="submit"]').first().click(); + await page.waitForTimeout(4000); + } + + await page.screenshot({ path: 'homarr-status.png' }); + console.log('Screenshot saved.'); + console.log('Current URL:', page.url()); + console.log('Page Title:', await page.title()); + + const content = await page.evaluate(() => document.body.innerText); + console.log('Homarr text:', content.slice(0, 1000)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/install-oidc-plugin-v2.spec.ts b/4-Infrastructure/k3s-flake/tests/install-oidc-plugin-v2.spec.ts new file mode 100644 index 00000000..f64a9e4f --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/install-oidc-plugin-v2.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +test('Install OIDC Plugin in Jellyfin', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + + // Wait for login input or home page element to load + console.log('Checking login state...'); + const loginInput = page.locator('input#txtManualName, input[type="text"]').first(); + const homeElement = page.locator('.skinHeader, #reactRoot').first(); + + // Wait up to 10 seconds for either to appear + try { + await Promise.race([ + loginInput.waitFor({ state: 'visible', timeout: 8000 }), + homeElement.waitFor({ state: 'visible', timeout: 8000 }) + ]); + } catch (err) { + console.log('Wait timeout, continuing anyway...'); + } + + if (await loginInput.isVisible()) { + console.log('Login form visible. Logging in manually...'); + await loginInput.fill('admin'); + const passwordInput = page.locator('input#txtManualPassword, input[type="password"]').first(); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"], button.button-submit').first().click(); + await page.waitForTimeout(6000); + } + + console.log('Ensuring we are logged in. URL:', page.url()); + await page.screenshot({ path: 'jellyfin-logged-in-state.png' }); + + // Navigate directly to Plugins page + console.log('Navigating directly to Plugins page...'); + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(5000); + + console.log('Plugins URL:', page.url()); + await page.screenshot({ path: 'jellyfin-plugins-loaded.png' }); + + // Click the "Available" tab using the filtered locator + console.log('Clicking Available tab...'); + const availableTab = page.locator('button, a, .tabButton, .emby-button, [role="tab"]').filter({ hasText: 'Available' }).first(); + await availableTab.waitFor({ state: 'visible', timeout: 5000 }); + await availableTab.click({ force: true }); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugins-available.png' }); + + // Search for OpenID Connect in catalog + console.log('Searching for OpenID Connect in catalog...'); + const searchInput = page.locator('input[type="search"], input[placeholder*="Search"]').first(); + if (await searchInput.isVisible()) { + await searchInput.fill('OpenID Connect'); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugins-search-results.png' }); + } + + // Click on the OpenID Connect card + console.log('Clicking OpenID Connect card...'); + const oidcCard = page.locator('a, .card, .cardHeader').filter({ hasText: 'OpenID Connect' }).first(); + await oidcCard.click({ force: true }); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugin-details.png' }); + + // Click Install + console.log('Clicking Install button...'); + const installBtn = page.locator('button, a, .emby-button').filter({ hasText: 'Install' }).first(); + await installBtn.click({ force: true }); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugin-install-clicked.png' }); + + // Handle OK button in modal dialog if any + const okBtn = page.locator('button, .emby-button').filter({ hasText: 'OK' }).first(); + if (await okBtn.isVisible()) { + console.log('Clicking OK on dialog...'); + await okBtn.click({ force: true }); + await page.waitForTimeout(3000); + await page.screenshot({ path: 'jellyfin-plugin-install-confirmed.png' }); + } + + console.log('OIDC Plugin installation completed.'); + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/install-oidc-plugin.spec.ts b/4-Infrastructure/k3s-flake/tests/install-oidc-plugin.spec.ts new file mode 100644 index 00000000..d0f82a3c --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/install-oidc-plugin.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; + +test('Install OIDC Plugin in Jellyfin', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin Plugins...'); + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Login if needed + const usernameInput = page.locator('input[type="text"], input#txtManualName'); + const passwordInput = page.locator('input[type="password"], input#txtManualPassword'); + + if (await usernameInput.isVisible()) { + console.log('Logging in...'); + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"], button.button-submit').first().click(); + await page.waitForTimeout(6000); + } + + // Double check we are on plugins page + if (!page.url().includes('#/dashboard/plugins')) { + await page.goto('https://media.researchstack.info/web/#/dashboard/plugins'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + } + + console.log('Clicking Available tab...'); + // Find the Available tab pill + const availableTab = page.locator('a:has-text("Available"), button:has-text("Available"), div:has-text("Available"), span:has-text("Available")').first(); + await availableTab.click({ force: true }); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugins-available.png' }); + + // Type in the search box to find OpenID Connect + console.log('Searching for OpenID Connect in catalog...'); + const searchInput = page.locator('input[type="search"], input[placeholder*="Search"]').first(); + if (await searchInput.isVisible()) { + await searchInput.fill('OpenID Connect'); + await page.waitForTimeout(3000); + await page.screenshot({ path: 'jellyfin-plugins-search-results.png' }); + } + + // Click on the OpenID Connect card + console.log('Clicking OpenID Connect card...'); + const oidcCard = page.locator('a:has-text("OpenID Connect"), .card:has-text("OpenID Connect"), .cardHeader:has-text("OpenID Connect")').first(); + await oidcCard.click({ force: true }); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugin-details.png' }); + + // Click Install + console.log('Clicking Install button...'); + const installBtn = page.locator('button:has-text("Install"), button.button-submit').first(); + await installBtn.click({ force: true }); + await page.waitForTimeout(4000); + await page.screenshot({ path: 'jellyfin-plugin-install-clicked.png' }); + + // Handle OK button in modal dialog if any + const okBtn = page.locator('button:has-text("OK"), button:has-text("Ok"), button.button-submit').first(); + if (await okBtn.isVisible()) { + console.log('Clicking OK on dialog...'); + await okBtn.click({ force: true }); + await page.waitForTimeout(3000); + await page.screenshot({ path: 'jellyfin-plugin-install-confirmed.png' }); + } + + console.log('OIDC Plugin installation completed.'); + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-check-initial.png b/4-Infrastructure/k3s-flake/tests/jellyfin-check-initial.png new file mode 100644 index 00000000..d0630ac2 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-check-initial.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-check.spec.ts b/4-Infrastructure/k3s-flake/tests/jellyfin-check.spec.ts new file mode 100644 index 00000000..a9607890 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/jellyfin-check.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test('Check Jellyfin with auth-state', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(6000); + + console.log('Current URL:', page.url()); + await page.screenshot({ path: 'jellyfin-check-initial.png' }); + + // If on consent page + if (page.url().includes('consent') || page.url().includes('authorize')) { + console.log('On consent/authorize page. Clicking Continue/Submit...'); + const btn = page.locator('button:has-text("Continue"), button:has-text("Authorize"), input[type="submit"]').first(); + if (await btn.isVisible()) { + await btn.click(); + await page.waitForTimeout(6000); + console.log('Clicked. New URL:', page.url()); + await page.screenshot({ path: 'jellyfin-check-after-consent.png' }); + } + } + + const textContent = await page.evaluate(() => document.body.innerText); + console.log('Jellyfin body text:', textContent.slice(0, 1000)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard-direct.png b/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard-direct.png new file mode 100644 index 00000000..0e59e453 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard-direct.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard-plugins-clicked.png b/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard-plugins-clicked.png new file mode 100644 index 00000000..01204229 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard-plugins-clicked.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard.png b/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard.png new file mode 100644 index 00000000..f83a5b5f Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-dashboard.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-direct.spec.ts b/4-Infrastructure/k3s-flake/tests/jellyfin-direct.spec.ts new file mode 100644 index 00000000..71f3c277 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/jellyfin-direct.spec.ts @@ -0,0 +1,38 @@ +import { test } from '@playwright/test'; +test('Jellyfin wizard + OIDC via direct port', async ({ page }) => { + test.setTimeout(180000); + + // Bypass Caddy forward_auth by going to Jellyfin directly + console.log('=== Connecting to Jellyfin directly ==='); + await page.goto('http://100.88.57.96:30091/web/#/wizard/start.html', { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(5000); + + console.log('URL:', page.url()); + console.log('Title:', await page.title()); + + // Check if we're on the wizard + const pageContent = await page.textContent('body'); + if (pageContent?.includes('wizard') || pageContent?.includes('Welcome')) { + console.log('Wizard page detected'); + } + + // Dump all visible elements + const buttons = await page.locator('button, .button-submit, [class*="button"]').all(); + console.log(`Found ${buttons.length} clickable elements`); + for (const btn of buttons) { + if (await btn.isVisible()) { + console.log(` Visible: "${await btn.textContent()}" tag=${await btn.evaluate(el => el.tagName)} class=${await btn.evaluate(el => el.className)}`); + } + } + + await page.screenshot({ path: '/tmp/jf-wizard-start.png', fullPage: true }); + + // Try clicking next/submit + const nextBtn = page.locator('button:has-text("Next"), .button-submit:visible, button[type="submit"]').first(); + if (await nextBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await nextBtn.click(); + await page.waitForTimeout(3000); + await page.screenshot({ path: '/tmp/jf-wizard-step2.png', fullPage: true }); + console.log('Clicked Next. URL:', page.url()); + } +}); diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-full-setup.spec.ts b/4-Infrastructure/k3s-flake/tests/jellyfin-full-setup.spec.ts new file mode 100644 index 00000000..1743b9fa --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/jellyfin-full-setup.spec.ts @@ -0,0 +1,120 @@ +import { test } from '@playwright/test'; +test('Jellyfin full setup via direct port', async ({ page }) => { + test.setTimeout(300000); + const BASE = 'http://100.88.57.96:30091'; + + // Step 1: Complete Jellyfin Setup Wizard + console.log('=== Wizard Step 1: Language ==='); + await page.goto(`${BASE}/web/#/wizard/start.html`, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(3000); + await page.locator('button.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + console.log('=== Wizard Step 2: Create Admin ==='); + await page.locator('#txtUsername').fill('admin'); + await page.locator('#txtManualPassword').fill('jellyfin-admin'); + await page.locator('#txtPasswordConfirm').fill('jellyfin-admin'); + await page.locator('button.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + console.log('=== Wizard Step 3: Libraries ==='); + await page.locator('button.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + console.log('=== Wizard Step 4: Metadata ==='); + await page.locator('button.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + console.log('=== Wizard Step 5: Remote Access ==='); + await page.locator('button.button-submit:visible').first().click(); + await page.waitForTimeout(3000); + + console.log('=== Wizard Step 6: Finish ==='); + await page.locator('button.button-submit:visible').first().click(); + await page.waitForTimeout(5000); + console.log('Wizard complete:', page.url()); + + // Step 2: Install OIDC Plugin + console.log('=== Installing OIDC Plugin ==='); + await page.goto(`${BASE}/web/#/dashboard/plugins/catalog`, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(5000); + + // Search for OIDC + const searchInput = page.locator('input[type="text"]').first(); + if (await searchInput.isVisible()) { + await searchInput.fill('OIDC'); + await page.waitForTimeout(3000); + } + + // Look for install buttons + const installBtns = page.locator('button:has-text("Install"):visible'); + const count = await installBtns.count(); + console.log(`Found ${count} install buttons`); + if (count > 0) { + await installBtns.first().click(); + await page.waitForTimeout(2000); + + // Confirm restart dialog if any + const confirmBtn = page.locator('button:has-text("Yes"):visible, button:has-text("Restart"):visible, button:has-text("OK"):visible').first(); + if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirmBtn.click(); + } + console.log('Plugin install initiated'); + } + + // Wait for Jellyfin to restart + console.log('Waiting for restart...'); + await page.waitForTimeout(30000); + + // Step 3: Navigate to OIDC plugin settings + console.log('=== Configuring OIDC Plugin ==='); + await page.goto(`${BASE}/web/#/dashboard/plugins`, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(5000); + + // Find the OIDC plugin in the list + const oidcLink = page.locator('a:has-text("OIDC"):visible, div.card:has-text("OIDC"):visible').first(); + if (await oidcLink.isVisible({ timeout: 5000 }).catch(() => false)) { + if ((await oidcLink.evaluate(el => el.tagName)) === 'A') { + await oidcLink.click(); + } else { + await oidcLink.locator('a').first().click(); + } + await page.waitForTimeout(3000); + + // Fill OIDC config + const issuerInput = page.locator('input:visible').first(); + if (await issuerInput.isVisible({ timeout: 3000 }).catch(() => false)) { + await issuerInput.fill('https://auth.researchstack.info'); + await page.waitForTimeout(500); + } + + // Fill client ID + const clientIdInput = page.locator('input:visible').nth(1); + if (await clientIdInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await clientIdInput.fill('sso-jellyfin'); + } + + // Fill client secret + const secretInput = page.locator('input:visible').nth(2); + if (await secretInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await secretInput.fill('fc96e87934f544bef43e5466c929baa73b681c4e5d0aa9fc5cafe6e6a36e2e49'); + } + + // Fill callback URL + const callbackInput = page.locator('input:visible').nth(3); + if (await callbackInput.isVisible({ timeout: 2000 }).catch(() => false)) { + await callbackInput.fill('https://media.researchstack.info/sso/OID/redirect'); + } + + // Hit Save + const saveBtn = page.locator('button:has-text("Save"):visible').first(); + if (await saveBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await saveBtn.click(); + await page.waitForTimeout(2000); + console.log('OIDC config saved'); + } + } + + await page.screenshot({ path: '/tmp/jf-oidc-done.png', fullPage: true }); + console.log('=== Jellyfin setup complete ==='); +}); diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-home.png b/4-Infrastructure/k3s-flake/tests/jellyfin-home.png new file mode 100644 index 00000000..6124090b Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-home.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-logged-in-state.png b/4-Infrastructure/k3s-flake/tests/jellyfin-logged-in-state.png new file mode 100644 index 00000000..6124090b Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-logged-in-state.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-login-debug.spec.ts b/4-Infrastructure/k3s-flake/tests/jellyfin-login-debug.spec.ts new file mode 100644 index 00000000..a143ee90 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/jellyfin-login-debug.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; + +test('Debug Jellyfin login', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + console.log('Initial URL:', page.url()); + await page.screenshot({ path: 'jellyfin-login-initial.png' }); + + const usernameInput = page.locator('input[type="text"], input#txtManualName'); + const passwordInput = page.locator('input[type="password"], input#txtManualPassword'); + + if (await usernameInput.isVisible()) { + console.log('Filling login form...'); + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await page.screenshot({ path: 'jellyfin-login-filled.png' }); + + console.log('Clicking login button...'); + const signInBtn = page.locator('button[type="submit"], button:has-text("Sign In")').first(); + await signInBtn.click(); + + // Wait for URL change or error + for (let i = 0; i < 10; i++) { + await page.waitForTimeout(1000); + console.log(`Step ${i}: URL is ${page.url()}`); + await page.screenshot({ path: `jellyfin-login-step-${i}.png` }); + + const bodyText = await page.evaluate(() => document.body.innerText); + if (bodyText.includes('Invalid') || bodyText.includes('incorrect') || bodyText.includes('failed')) { + console.log('Login failed message detected!'); + break; + } + } + } else { + console.log('No login inputs found!'); + } + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-login-filled.png b/4-Infrastructure/k3s-flake/tests/jellyfin-login-filled.png new file mode 100644 index 00000000..c1aed0c2 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-login-filled.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-login-initial.png b/4-Infrastructure/k3s-flake/tests/jellyfin-login-initial.png new file mode 100644 index 00000000..d0630ac2 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-login-initial.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-login-step-0.png b/4-Infrastructure/k3s-flake/tests/jellyfin-login-step-0.png new file mode 100644 index 00000000..42508eaa Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-login-step-0.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-plugins-loaded.png b/4-Infrastructure/k3s-flake/tests/jellyfin-plugins-loaded.png new file mode 100644 index 00000000..dc356aa3 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-plugins-loaded.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-plugins.png b/4-Infrastructure/k3s-flake/tests/jellyfin-plugins.png new file mode 100644 index 00000000..2a0bc4b0 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-plugins.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-status-loaded.png b/4-Infrastructure/k3s-flake/tests/jellyfin-status-loaded.png new file mode 100644 index 00000000..d0630ac2 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-status-loaded.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-status.png b/4-Infrastructure/k3s-flake/tests/jellyfin-status.png new file mode 100644 index 00000000..c558c1e2 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-status.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-check.spec.ts b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-check.spec.ts new file mode 100644 index 00000000..f1db81a7 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-check.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +test('Check Jellyfin wizard', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Jellyfin wizard...'); + await page.goto('https://media.researchstack.info/web/#/wizardstart.html'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(6000); + + console.log('URL:', page.url()); + await page.screenshot({ path: 'jellyfin-wizard.png' }); + + const textContent = await page.evaluate(() => document.body.innerText); + console.log('Jellyfin text:', textContent.slice(0, 1000)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step1.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step1.png new file mode 100644 index 00000000..1338c398 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step1.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step2-filled.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step2-filled.png new file mode 100644 index 00000000..0d30876f Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step2-filled.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step2.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step2.png new file mode 100644 index 00000000..30ea2a4b Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step2.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3-debug.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3-debug.png new file mode 100644 index 00000000..2c7e8403 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3-debug.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3.png new file mode 100644 index 00000000..0375625f Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step3.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step4.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step4.png new file mode 100644 index 00000000..2dcfbbfd Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step4.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5-debug.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5-debug.png new file mode 100644 index 00000000..e5ad70be Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5-debug.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5.png new file mode 100644 index 00000000..e5ad70be Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-step5.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-start.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-start.png new file mode 100644 index 00000000..1338c398 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-start.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step1.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step1.png new file mode 100644 index 00000000..1338c398 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step1.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step2-filled.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step2-filled.png new file mode 100644 index 00000000..0d30876f Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step2-filled.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step2.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step2.png new file mode 100644 index 00000000..30ea2a4b Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step2.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step3.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step3.png new file mode 100644 index 00000000..2a08bc2c Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step3.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step4.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step4.png new file mode 100644 index 00000000..2dcfbbfd Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step4.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step5.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step5.png new file mode 100644 index 00000000..e5ad70be Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v2-step5.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-finished.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-finished.png new file mode 100644 index 00000000..d0630ac2 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-finished.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-home.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-home.png new file mode 100644 index 00000000..6124090b Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-home.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-start.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-start.png new file mode 100644 index 00000000..1338c398 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-start.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step1.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step1.png new file mode 100644 index 00000000..1338c398 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step1.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step2-filled.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step2-filled.png new file mode 100644 index 00000000..0d30876f Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step2-filled.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step2.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step2.png new file mode 100644 index 00000000..30ea2a4b Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step2.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step3.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step3.png new file mode 100644 index 00000000..6fe1cd41 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step3.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step4.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step4.png new file mode 100644 index 00000000..2dcfbbfd Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step4.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step5.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step5.png new file mode 100644 index 00000000..e5ad70be Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step5.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step6.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step6.png new file mode 100644 index 00000000..675e6fd7 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard-v3-step6.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin-wizard.png b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard.png new file mode 100644 index 00000000..1338c398 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/jellyfin-wizard.png differ diff --git a/4-Infrastructure/k3s-flake/tests/jellyfin.spec.ts b/4-Infrastructure/k3s-flake/tests/jellyfin.spec.ts new file mode 100644 index 00000000..a4041234 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/jellyfin.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; + +test('Configure Jellyfin OIDC', async ({ page }) => { + console.log('Navigating to Jellyfin...'); + await page.goto('https://media.researchstack.info/'); + await page.waitForTimeout(6000); + + // If redirected to Authentik login page + if (page.url().includes('/flow/default-authentication-flow/')) { + console.log('Redirected to Authentik login page. Logging in...'); + await page.locator('input[name="uid"], input[id*="uid"], input[type="text"]').first().fill('akadmin'); + await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click(); + await page.waitForTimeout(1000); + + await page.locator('input[name="password"], input[id*="password"], input[type="password"]').first().fill('authentik'); + await page.locator('button[type="submit"], input[type="submit"], button:has-text("Continue")').first().click(); + await page.waitForTimeout(5000); + } + + // If redirected to consent page + if (page.url().includes('default-provider-authorization-explicit-consent')) { + console.log('On consent page. Clicking Continue...'); + const continueBtn = page.locator('button:has-text("Continue"), input[type="submit"]'); + await continueBtn.first().click(); + await page.waitForTimeout(10000); + } + + console.log('Current URL after auth flow:', page.url()); + + // We should now be on the Jellyfin home or login page + const usernameInput = page.locator('#txtManualName'); + const passwordInput = page.locator('#txtManualPassword'); + + if (await usernameInput.isVisible()) { + console.log('Logging in to Jellyfin...'); + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + const signInBtn = page.locator('button[type="submit"], button:has-text("Sign In")').first(); + await signInBtn.click(); + await page.waitForTimeout(8000); + } + + await page.screenshot({ path: 'jellyfin-dashboard.png' }); + console.log('Final Jellyfin URL:', page.url()); + console.log('Final Jellyfin Title:', await page.title()); + + const textContent = await page.evaluate(() => document.body.innerText); + console.log('Jellyfin text:', textContent.slice(0, 1000)); +}); diff --git a/4-Infrastructure/k3s-flake/tests/trailbase.spec.ts b/4-Infrastructure/k3s-flake/tests/trailbase.spec.ts new file mode 100644 index 00000000..35d24a0f --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/trailbase.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; + +test('Configure TrailBase SSO', async ({ page }) => { + console.log('Navigating to TrailBase admin portal...'); + await page.goto('https://trailbase.researchstack.info/_/admin/'); + await page.waitForLoadState('networkidle'); + + // Fill credentials + const emailInput = page.locator('input[type="email"]'); + const passwordInput = page.locator('input[type="password"]'); + + if (await emailInput.isVisible()) { + console.log('Logging in...'); + await emailInput.fill('admin@localhost'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + const loginBtn = page.locator('button[type="submit"]'); + await loginBtn.first().click(); + await page.waitForTimeout(3000); + } + + // Click settings link + console.log('Clicking settings link...'); + await page.locator('a[href="/_/admin/settings/"]').click(); + await page.waitForTimeout(3000); + + // Click Auth submenu + console.log('Clicking Auth settings...'); + await page.locator('text="Auth"').first().click(); + await page.waitForTimeout(3000); + + // Find OpenID Connect toggle circle + console.log('Locating OpenID Connect trigger...'); + const oidcItem = page.locator('div.border-b:has(button:has-text("OpenID Connect"))'); + const oidcTrigger = oidcItem.locator('button').first(); + const toggleCircle = oidcTrigger.locator('svg').first(); + + // Click toggle circle (this both enables it and expands the accordion) + console.log('Clicking OIDC toggle circle to enable and expand...'); + await toggleCircle.click(); + await page.waitForTimeout(3000); // Wait for expansion animation to complete + + // Helper function to fill fields dynamically by label text + const fillFieldByLabel = async (labelText: string, value: string) => { + const inputId = await page.evaluate((text) => { + const labels = Array.from(document.querySelectorAll('label')); + const label = labels.find(l => l.textContent?.trim() === text); + if (!label) return null; + + // Traverse up to find outer label with 'for' attribute + let outerLabel: HTMLElement | null = label; + while (outerLabel && outerLabel.tagName !== 'LABEL') { + outerLabel = outerLabel.parentElement; + } + if (outerLabel) { + const forAttr = outerLabel.getAttribute('for'); + if (forAttr) return forAttr; + } + + // Fallback: look at parent grid and find input + const parent = label.closest('.grid'); + if (parent) { + const input = parent.querySelector('input'); + if (input) return input.id; + } + return null; + }, labelText); + + if (inputId) { + console.log(`Filling field "${labelText}" (ID: ${inputId})...`); + await page.locator(`#${inputId}`).fill(value); + } else { + throw new Error(`Could not find input ID for label "${labelText}"`); + } + }; + + // Fill OIDC fields + console.log('Filling OIDC fields...'); + await fillFieldByLabel('Client Id', 'sso-trailbase'); + await fillFieldByLabel('Client Secret', '64810e7e2960494a4ba5340bb0c1b19af3e61e17c6f1c6f6eb7ed9aa72cabb1a'); + await fillFieldByLabel('Auth URL', 'https://auth.researchstack.info/application/o/authorize/'); + await fillFieldByLabel('Token URL', 'https://auth.researchstack.info/application/o/token/'); + await fillFieldByLabel('User API URL', 'https://auth.researchstack.info/application/o/userinfo/'); + + // Click submit button at the bottom of settings page + console.log('Clicking Save/Submit...'); + const submitBtn = page.locator('button[type="submit"], button:has-text("Submit"), button:has-text("Save")').first(); + await submitBtn.click(); + await page.waitForTimeout(4000); + + await page.screenshot({ path: 'trailbase-oidc-saved.png' }); + console.log('Finished TrailBase OIDC configuration.'); +}); diff --git a/4-Infrastructure/k3s-flake/tests/update-outpost.spec.ts b/4-Infrastructure/k3s-flake/tests/update-outpost.spec.ts new file mode 100644 index 00000000..c1572512 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/update-outpost.spec.ts @@ -0,0 +1,186 @@ +import { test, expect } from '@playwright/test'; + +test('Configure Authentik Outpost Applications', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + console.log('Navigating to Authentik admin home...'); + await page.goto('https://auth.researchstack.info/if/admin/#/outpost/outposts'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // Login + const loginInput = page.locator('input[placeholder*="Username"], input[name="uid"], input[type="text"]').first(); + if (await loginInput.isVisible()) { + console.log('Logging in...'); + await loginInput.fill('akadmin'); + const continueBtn = page.locator('button:has-text("Log in"), button:has-text("Continue"), input[type="submit"]').first(); + await continueBtn.click(); + await page.waitForTimeout(2000); + const passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + await passwordInput.fill('authentik'); + await continueBtn.click(); + await page.waitForTimeout(6000); + } + + // Go to outposts + if (!page.url().includes('#/outpost/outposts')) { + await page.goto('https://auth.researchstack.info/if/admin/#/outpost/outposts'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + } + + console.log('On outposts page. Capturing screenshot...'); + await page.screenshot({ path: 'authentik-outposts-page.png' }); + + // Piercing shadow DOM to find the Edit button + // Let's find "authentik Embedded Outpost" and click the Edit button in its row + console.log('Looking for Edit button...'); + + // Let's run a script in browser context to click the Edit button inside the shadow DOM + const clicked = await page.evaluate(() => { + function findAndClickEdit(): boolean { + // Find the row containing "authentik Embedded Outpost" + // In Authentik UI, the outposts list is typically rendered by a web component ak-table or similar + const rows = document.querySelectorAll('*'); + for (const el of Array.from(rows)) { + // If it is an element with text or label + if (el.shadowRoot) { + const innerRows = el.shadowRoot.querySelectorAll('tr, div.tr, ak-table-row'); + for (const row of Array.from(innerRows)) { + const rowText = (row as HTMLElement).innerText || ''; + if (rowText.includes('authentik Embedded Outpost') || rowText.includes('Embedded')) { + // Find the edit button in this row or shadow root of the row + const buttons = row.querySelectorAll('button, a, [role="button"]'); + for (const btn of Array.from(buttons)) { + if (btn.textContent?.includes('Edit') || btn.getAttribute('title')?.includes('Edit') || btn.innerHTML.includes('edit')) { + (btn as HTMLElement).click(); + return true; + } + } + // Check shadow root of row elements + const cellElements = row.querySelectorAll('*'); + for (const cell of Array.from(cellElements)) { + if (cell.shadowRoot) { + const shadowBtns = cell.shadowRoot.querySelectorAll('button, a'); + for (const sBtn of Array.from(shadowBtns)) { + if (sBtn.textContent?.includes('Edit') || sBtn.getAttribute('title')?.includes('Edit')) { + (sBtn as HTMLElement).click(); + return true; + } + } + } + } + } + } + } + } + + // Fallback: search globally for any edit button or action button next to "Embedded Outpost" + const anyEdit = document.querySelector('button[title*="Edit"], ak-table-row button'); + if (anyEdit) { + (anyEdit as HTMLElement).click(); + return true; + } + return false; + } + return findAndClickEdit(); + }); + + console.log('Clicked edit button:', clicked); + await page.waitForTimeout(3000); + await page.screenshot({ path: 'authentik-outpost-edit-clicked.png' }); + + // In the edit modal, check the available applications + // Let's see if we can select all options in the multi-select + console.log('Checking modal multi-select...'); + const selectedAll = await page.evaluate(() => { + // Look for multi-select element or list of applications + // Usually it has a select element with multiple attribute, or a search input + // Let's search inside the open ak-modal-dialog + const modal = document.querySelector('ak-modal-dialog, div[role="dialog"]'); + if (!modal) return 'No modal found'; + + // Find all select elements inside the modal shadow root + function findSelects(root: Element | ShadowRoot): HTMLSelectElement[] { + const results: HTMLSelectElement[] = []; + const selects = root.querySelectorAll('select'); + selects.forEach(s => results.push(s)); + const all = root.querySelectorAll('*'); + all.forEach(el => { + if (el.shadowRoot) { + results.push(...findSelects(el.shadowRoot)); + } + }); + return results; + } + + const rootToSearch = modal.shadowRoot || modal; + const selects = findSelects(rootToSearch); + if (selects.length > 0) { + const select = selects[0]; + // Select all options + let selectedNames: string[] = []; + for (let i = 0; i < select.options.length; i++) { + select.options[i].selected = true; + selectedNames.push(select.options[i].text || select.options[i].value); + } + // Trigger change event + select.dispatchEvent(new Event('change', { bubbles: true })); + select.dispatchEvent(new Event('input', { bubbles: true })); + + // Find the update button to submit the form + let submitBtn: HTMLElement | null = null; + const modalBtns = rootToSearch.querySelectorAll('button, input[type="submit"]'); + for (const btn of Array.from(modalBtns)) { + if (btn.textContent?.includes('Update') || btn.textContent?.includes('Save') || btn.textContent?.includes('Submit')) { + submitBtn = btn as HTMLElement; + break; + } + } + if (submitBtn) { + submitBtn.click(); + return `Selected options: ${selectedNames.join(', ')}. Clicked submit.`; + } + return `Selected options: ${selectedNames.join(', ')}. Could not find submit button.`; + } + + // Fallback: dual list select + // Let's check if there is an ak-duallist-select component + const duallist = modal.querySelector('ak-duallist-select') || rootToSearch.querySelector('ak-duallist-select'); + if (duallist) { + // dual-list selector has custom properties or shadow DOM + // Let's look inside its shadow root + const dlRoot = duallist.shadowRoot || duallist; + // It usually has "Add all" button or similar, or checkboxes + const addAllBtn = dlRoot.querySelector('button[title*="Add all"], button:has-text("Add all")') || + Array.from(dlRoot.querySelectorAll('button')).find(b => b.textContent?.includes('Add all') || b.innerHTML.includes('forward')); + if (addAllBtn) { + (addAllBtn as HTMLElement).click(); + + // Find submit button in main modal + let submitBtn: HTMLElement | null = null; + const modalBtns = rootToSearch.querySelectorAll('button, input[type="submit"]'); + for (const btn of Array.from(modalBtns)) { + if (btn.textContent?.includes('Update') || btn.textContent?.includes('Save') || btn.textContent?.includes('Submit')) { + submitBtn = btn as HTMLElement; + break; + } + } + if (submitBtn) { + submitBtn.click(); + return 'Found duallist, clicked Add all, clicked submit.'; + } + return 'Found duallist, clicked Add all, but no submit button.'; + } + } + + return 'No standard select or duallist found in modal'; + }); + + console.log('Selected all result:', selectedAll); + await page.waitForTimeout(5000); + await page.screenshot({ path: 'authentik-outpost-updated.png' }); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/uptime-kuma-dashboard.png b/4-Infrastructure/k3s-flake/tests/uptime-kuma-dashboard.png new file mode 100644 index 00000000..54ab5aa6 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/uptime-kuma-dashboard.png differ diff --git a/4-Infrastructure/k3s-flake/tests/uptime-kuma-logged-in.png b/4-Infrastructure/k3s-flake/tests/uptime-kuma-logged-in.png new file mode 100644 index 00000000..f36893f0 Binary files /dev/null and b/4-Infrastructure/k3s-flake/tests/uptime-kuma-logged-in.png differ diff --git a/4-Infrastructure/k3s-flake/tests/uptime-kuma-settings.spec.ts b/4-Infrastructure/k3s-flake/tests/uptime-kuma-settings.spec.ts new file mode 100644 index 00000000..1a5fc53d --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/uptime-kuma-settings.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; + +test('Check Uptime Kuma settings', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Uptime Kuma...'); + await page.goto('https://uptime.researchstack.info/dashboard'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + // If we see login inputs + const usernameInput = page.locator('input[type="text"]'); + const passwordInput = page.locator('input[type="password"]'); + + if (await usernameInput.isVisible()) { + console.log('Logging in with admin...'); + await usernameInput.fill('admin'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(6000); + } + + console.log('Current URL:', page.url()); + await page.screenshot({ path: 'uptime-kuma-logged-in.png' }); + + // Let's click the user icon/menu or navigate to /settings + // Wait, let's look at the DOM to see how to go to settings. + const textContent = await page.evaluate(() => document.body.innerText); + console.log('Uptime Kuma text:', textContent.slice(0, 1000)); + + await context.close(); +}); diff --git a/4-Infrastructure/k3s-flake/tests/uptime-kuma.spec.ts b/4-Infrastructure/k3s-flake/tests/uptime-kuma.spec.ts new file mode 100644 index 00000000..1a376dd4 --- /dev/null +++ b/4-Infrastructure/k3s-flake/tests/uptime-kuma.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; + +test('Configure Uptime Kuma Admin', async ({ browser }) => { + const context = await browser.newContext({ storageState: 'auth-state.json' }); + const page = await context.newPage(); + + console.log('Navigating to Uptime Kuma...'); + await page.goto('https://uptime.researchstack.info/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(4000); + + if (page.url().includes('default-provider-authorization-explicit-consent')) { + await page.locator('button:has-text("Continue"), input[type="submit"]').first().click(); + await page.waitForTimeout(4000); + } + + console.log('Current URL:', page.url()); + + if (page.url().includes('/setup')) { + console.log('Filling setup wizard form...'); + + const usernameInput = page.locator('#floatingInput'); + await expect(usernameInput).toBeVisible(); + await usernameInput.fill('admin'); + + const passwordInput = page.locator('#floatingPassword'); + await passwordInput.fill('RY03KhsFez73K5va2uUb'); + + const repeatInput = page.locator('#repeat'); + await repeatInput.fill('RY03KhsFez73K5va2uUb'); + + // Click Create + console.log('Clicking Create...'); + const createBtn = page.locator('button[type="submit"]'); + await createBtn.click(); + await page.waitForTimeout(5000); + } else { + console.log('Already past setup page.'); + } + + await page.screenshot({ path: 'uptime-kuma-dashboard.png' }); + console.log('Final page URL:', page.url()); + console.log('Final page title:', await page.title()); + + await context.close(); +}); diff --git a/4-Infrastructure/kube/manifests/porkbun.env b/4-Infrastructure/kube/manifests/porkbun.env new file mode 100644 index 00000000..f46c7ac1 --- /dev/null +++ b/4-Infrastructure/kube/manifests/porkbun.env @@ -0,0 +1,3 @@ +# Porkbun DNS API credentials for researchstack.info +PORKBUN_API_KEY=***REMOVED*** +PORKBUN_SECRET_KEY=***REMOVED*** diff --git a/4-Infrastructure/shim/reservoir.py b/4-Infrastructure/shim/reservoir.py new file mode 100644 index 00000000..f05e02ba --- /dev/null +++ b/4-Infrastructure/shim/reservoir.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +""" +Reservoir CLI — FTP-backed storage with local caching and receipt chain. +Works without network by operating on local cache, syncs to FTP reservoir. +""" + +import argparse, base64, hashlib, json, os, shutil, sys, time +from pathlib import Path +from typing import Optional + +try: + import pycurl + HAS_CURL = True +except ImportError: + HAS_CURL = False + + +# ─── Q16_16 helpers ───────────────────────────────────────────────────────── + +class Q16_16: + @staticmethod + def of_int(x: int) -> int: + return x << 16 + + @staticmethod + def to_int(x: int) -> int: + return x >> 16 + + +# ─── Receipt ID ───────────────────────────────────────────────────────────── + +def receipt_id() -> str: + return hashlib.sha256(str(time.time_ns()).encode()).hexdigest()[:32] + +def now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + +def file_hash(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return "sha256:" + h.hexdigest() + + +# ─── FTP Operations (curl-based, no extra deps) ───────────────────────────── + +class FTPClient: + def __init__(self, host: str, user: str, password: str, base_path: str = "/htdocs"): + self.host = host + self.user = user + self.password = password + self.base_path = base_path.rstrip("/") + self.curl = None + if HAS_CURL: + self.curl = pycurl.Curl() + self.curl.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 1) + self.curl.setopt(pycurl.FTP_USE_EPSV, 0) + + def _url(self, path: str) -> str: + p = (self.base_path + "/" + path.lstrip("/")).replace("//", "/") + return f"ftp://{self.host}{p}" + + def list(self, path: str = "") -> list[str]: + cmd = f"LIST{'-la' if True else ''}" + out = [] + c = pycurl.Curl() + c.setopt(pycurl.URL, self._url(path)) + c.setopt(pycurl.USERNAME, self.user) + c.setopt(pycurl.PASSWORD, self.password) + c.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 0) + c.setopt(pycurl.NOBODY, 0) + c.setopt(pycurl.VERBOSE, 0) + import io + buf = io.BytesIO() + c.setopt(pycurl.WRITEFUNCTION, buf.write) + try: + c.perform() + raw = buf.getvalue().decode() + for line in raw.split("\n"): + if line.strip(): + out.append(line) + except Exception as e: + pass + c.close() + return out + + def mkdir(self, path: str) -> bool: + c = pycurl.Curl() + c.setopt(pycurl.URL, self._url(path)) + c.setopt(pycurl.USERNAME, self.user) + c.setopt(pycurl.PASSWORD, self.password) + c.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 1) + c.setopt(pycurl.CUSTOMREQUEST, "MKD " + path) + try: + c.perform() + c.close() + return True + except Exception: + return False + + def upload_file(self, local_path: Path, remote_path: str) -> bool: + c = pycurl.Curl() + c.setopt(pycurl.UPLOAD, 1) + c.setopt(pycurl.URL, self._url(remote_path)) + c.setopt(pycurl.USERNAME, self.user) + c.setopt(pycurl.PASSWORD, self.password) + c.setopt(pycurl.FTP_CREATE_MISSING_DIRS, 1) + f = open(local_path, "rb") + c.setopt(pycurl.READDATA, f) + try: + c.perform() + f.close() + c.close() + return True + except Exception as e: + f.close() + c.close() + return False + + def download_file(self, remote_path: str, local_path: Path) -> bool: + import io + c = pycurl.Curl() + c.setopt(pycurl.URL, self._url(remote_path)) + c.setopt(pycurl.USERNAME, self.user) + c.setopt(pycurl.PASSWORD, self.password) + buf = io.BytesIO() + c.setopt(pycurl.WRITEFUNCTION, buf.write) + try: + c.perform() + local_path.write_bytes(buf.getvalue()) + c.close() + return True + except Exception: + c.close() + return False + + def exists(self, path: str) -> bool: + c = pycurl.Curl() + c.setopt(pycurl.NOBODY, 1) + c.setopt(pycurl.URL, self._url(path)) + c.setopt(pycurl.USERNAME, self.user) + c.setopt(pycurl.PASSWORD, self.password) + try: + c.perform() + code = c.getinfo(pycurl.HTTP_CODE) + c.close() + return code in (200, 226, 250) + except Exception: + c.close() + return False + + def delete(self, path: str) -> bool: + c = pycurl.Curl() + c.setopt(pycurl.URL, self._url(path)) + c.setopt(pycurl.USERNAME, self.user) + c.setopt(pycurl.PASSWORD, self.password) + c.setopt(pycurl.CUSTOMREQUEST, "DELE " + path) + try: + c.perform() + c.close() + return True + except Exception: + c.close() + return False + + +# ─── Local Reservoir ────────────────────────────────────────────────────────── + +class LocalReservoir: + """Filesystem-backed reservoir with manifest + receipts.""" + + def __init__(self, root: Path): + self.root = Path(root).resolve() + self.storage = self.root / "storage" + self.uploads = self.storage / "uploads" + self.receipts_dir = self.storage / "receipts" + self.manifest_file = self.storage / "manifest.json" + for d in [self.storage, self.uploads, self.receipts_dir]: + d.mkdir(parents=True, exist_ok=True) + + def _manifest(self) -> dict: + if self.manifest_file.exists(): + return json.loads(self.manifest_file.read_text()) + return {} + + def _save_manifest(self, m: dict): + self.manifest_file.write_text(json.dumps(m, indent=2)) + + def health(self) -> dict: + m = self._manifest() + return { + "status": "reservoir-up", + "version": "1.0.0", + "mode": "local", + "timestamp": now_iso(), + "root": str(self.root), + "package_count": len(m), + } + + def list_packages(self) -> dict: + m = self._manifest() + return {"packages": [{"pkg": k, **v} for k, v in m.items()], "total": len(m)} + + def get_package(self, pkg: str) -> dict: + m = self._manifest() + if pkg not in m: + raise FileNotFoundError(f"Package '{pkg}' not found") + return {"pkg": pkg, **m[pkg]} + + def register_bytes(self, pkg: str, data: bytes, promotion_state: str = "held", + source: str = "local") -> dict: + content_hash = "sha256:" + hashlib.sha256(data).hexdigest() + size = len(data) + ch_short = content_hash[7:] + + # Write content file + (self.uploads / (ch_short + ".bin")).write_bytes(data) + + # Update manifest + m = self._manifest() + now = now_iso() + if pkg in m: + old = m[pkg]["created_at"] + m[pkg] = { + "pkg": pkg, + "content_hash": content_hash, + "size_bytes": size, + "promotion_state": promotion_state, + "source": source, + "created_at": old, + "updated_at": now, + } + else: + m[pkg] = { + "pkg": pkg, + "content_hash": content_hash, + "size_bytes": size, + "promotion_state": promotion_state, + "source": source, + "created_at": now, + "updated_at": now, + } + self._save_manifest(m) + + # Write receipt + rid = receipt_id() + receipt = { + "id": rid, + "pkg": pkg, + "receipt_type": "package_register", + "content_hash": content_hash, + "size_bytes": size, + "promotion_state": promotion_state, + "source": source, + "created_at": now, + } + (self.receipts_dir / f"{rid}.json").write_text(json.dumps(receipt, indent=2)) + return {"status": "registered", "pkg": pkg, "content_hash": content_hash, "receipt_id": rid} + + def register_file(self, pkg: str, path: Path, promotion_state: str = "held") -> dict: + return self.register_bytes(pkg, path.read_bytes(), promotion_state, source="local-file") + + def update_promotion(self, pkg: str, new_state: str) -> dict: + m = self._manifest() + if pkg not in m: + raise FileNotFoundError(f"Package '{pkg}' not found") + old_state = m[pkg].get("promotion_state", "held") + now = now_iso() + m[pkg]["promotion_state"] = new_state + m[pkg]["updated_at"] = now + self._save_manifest(m) + + rid = receipt_id() + receipt = { + "id": rid, + "pkg": pkg, + "receipt_type": "promotion_change", + "from_state": old_state, + "to_state": new_state, + "created_at": now, + } + (self.receipts_dir / f"{rid}.json").write_text(json.dumps(receipt, indent=2)) + return {"status": "promotion_updated", "pkg": pkg, "from": old_state, "to": new_state} + + def list_receipts(self) -> list[dict]: + receipts = [] + for f in sorted(self.receipts_dir.glob("*.json")): + receipts.append(json.loads(f.read_text())) + return receipts + + def download(self, pkg: str) -> bytes: + m = self._manifest() + if pkg not in m: + raise FileNotFoundError(f"Package '{pkg}' not found") + ch = m[pkg]["content_hash"] + return (self.uploads / (ch[7:] + ".bin")).read_bytes() + + def delete(self, pkg: str) -> dict: + m = self._manifest() + if pkg not in m: + raise FileNotFoundError(f"Package '{pkg}' not found") + ch = m[pkg]["content_hash"] + uf = self.uploads / (ch[7:] + ".bin") + if uf.exists(): + uf.unlink() + del m[pkg] + self._save_manifest(m) + return {"status": "deleted", "pkg": pkg} + + +# ─── FTP Sync ─────────────────────────────────────────────────────────────── + +class FTPSync: + """Push/pull local reservoir ↔ FTP endpoint.""" + + def __init__(self, ftp: FTPClient, local: LocalReservoir): + self.ftp = ftp + self.local = local + + def push_all(self) -> dict: + """Push all local packages to FTP reservoir.""" + manifest = self.local._manifest() + pushed = [] + errors = [] + + # Ensure remote directories exist + self.ftp.mkdir("storage/uploads") + self.ftp.mkdir("storage/receipts") + + for pkg, info in manifest.items(): + ch = info["content_hash"] + local_file = self.local.uploads / (ch[7:] + ".bin") + if not local_file.exists(): + errors.append({"pkg": pkg, "error": "content file missing"}) + continue + + # Upload content + ok = self.ftp.upload_file(local_file, f"storage/uploads/{ch[7:]}.bin") + if not ok: + errors.append({"pkg": pkg, "error": "upload failed"}) + continue + + # Upload receipt if exists + receipts = self.local.list_receipts() + pkg_receipts = [r for r in receipts if r["pkg"] == pkg] + for r in pkg_receipts: + rid = r["id"] + local_receipt = self.local.receipts_dir / f"{rid}.json" + if local_receipt.exists(): + self.ftp.upload_file(local_receipt, f"storage/receipts/{rid}.json") + + pushed.append(pkg) + + # Upload manifest + manifest_bytes = json.dumps(manifest, indent=2).encode() + self.ftp.upload_file( + self.local.manifest_file, + "storage/manifest.json" + ) + + return {"pushed": pushed, "total": len(pushed), "errors": errors} + + def pull_all(self) -> dict: + """Pull all packages from FTP reservoir to local.""" + if not self.ftp.exists("storage/manifest.json"): + return {"pulled": [], "total": 0, "errors": ["no remote manifest"]} + + # Download manifest + tmp_manifest = self.local.storage / "manifest.remote.json" + if not self.ftp.download_file("storage/manifest.json", tmp_manifest): + return {"pulled": [], "total": 0, "errors": ["failed to download manifest"]} + + remote_m = json.loads(tmp_manifest.read_text()) + local_m = self.local._manifest() + local_pkgs = set(local_m.keys()) + remote_pkgs = set(remote_m.keys()) + + pulled = [] + errors = [] + + for pkg in remote_pkgs: + if pkg in local_pkgs: + # Skip existing unless content differs + local_hash = local_m[pkg].get("content_hash") + remote_hash = remote_m[pkg].get("content_hash") + if local_hash == remote_hash: + continue + + ch = remote_m[pkg]["content_hash"] + remote_content = f"storage/uploads/{ch[7:]}.bin" + local_content = self.local.uploads / (ch[7:] + ".bin") + + if self.ftp.download_file(remote_content, local_content): + # Update local manifest + local_m[pkg] = remote_m[pkg] + local_m[pkg]["created_at"] = remote_m[pkg].get("created_at", now_iso()) + local_m[pkg]["updated_at"] = now_iso() + pulled.append(pkg) + else: + errors.append({"pkg": pkg, "error": "content download failed"}) + + self.local._save_manifest(local_m) + + # Sync receipts + if self.ftp.exists("storage/receipts"): + for pkg in pulled: + receipts = self.local.list_receipts() + for r in receipts: + rid = r["id"] + local_receipt = self.local.receipts_dir / f"{rid}.json" + if not local_receipt.exists(): + self.ftp.download_file(f"storage/receipts/{rid}.json", local_receipt) + + tmp_manifest.unlink() + return {"pulled": pulled, "total": len(pulled), "errors": errors} + + def push_receipt(self, receipt: dict) -> bool: + rid = receipt["id"] + path = self.local.receipts_dir / f"{rid}.json" + if path.exists(): + return self.ftp.upload_file(path, f"storage/receipts/{rid}.json") + return False + + +# ─── CLI ──────────────────────────────────────────────────────────────────── + +def cmd_health(rv, args): + print(json.dumps(rv.health(), indent=2)) + +def cmd_list(rv, args): + result = rv.list_packages() + print(json.dumps(result, indent=2)) + +def cmd_register(rv, args): + path = Path(args.file) + if not path.exists(): + print(f"ERROR: file not found: {path}", file=sys.stderr) + return 1 + result = rv.register_file(args.pkg or path.stem, path, args.state) + print(json.dumps(result, indent=2)) + return 0 + +def cmd_promote(rv, args): + print(json.dumps(rv.update_promotion(args.pkg, args.state), indent=2)) + +def cmd_receipts(rv, args): + print(json.dumps(rv.list_receipts(), indent=2)) + +def cmd_download(rv, args): + data = rv.download(args.pkg) + out = Path(args.output) if args.output else Path(args.pkg + ".bin") + out.write_bytes(data) + print(f"Downloaded → {out} ({len(data)} bytes)") + +def cmd_delete(rv, args): + print(json.dumps(rv.delete(args.pkg), indent=2)) + +def cmd_push(rv, args): + ftp = FTPClient(args.ftp_host, args.ftp_user, args.ftp_pass) + sync = FTPSync(ftp, rv) + result = sync.push_all() + print(json.dumps(result, indent=2)) + +def cmd_pull(rv, args): + ftp = FTPClient(args.ftp_host, args.ftp_user, args.ftp_pass) + sync = FTPSync(ftp, rv) + result = sync.pull_all() + print(json.dumps(result, indent=2)) + +def cmd_seed(rv, args): + """Seed the reservoir with initial packages from a directory.""" + src = Path(args.src) + if not src.exists(): + print(f"ERROR: source directory not found: {src}", file=sys.stderr) + return 1 + + seeded = [] + for f in src.rglob("*"): + if f.is_file() and not f.name.startswith("."): + pkg_name = str(f.relative_to(src)).replace("/", "__") + try: + result = rv.register_file(pkg_name, f, "held") + seeded.append(pkg_name) + except Exception as e: + print(f" SKIP {pkg_name}: {e}", file=sys.stderr) + + print(f"Seeded {len(seeded)} packages from {src}") + return 0 + + +def main(): + p = argparse.ArgumentParser(description="Reservoir CLI — FTP-backed research storage") + sub = p.add_subparsers(dest="cmd", required=True) + + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--root", default=os.environ.get("RESERVOIR_ROOT", "./reservoir-data"), + help="Local reservoir root") + + # health + sub.add_parser("health", parents=[common], help="Health check") + + # list + sub.add_parser("list", parents=[common], help="List packages") + + # register + reg = sub.add_parser("register", parents=[common], help="Register a package") + reg.add_argument("file", help="File to register") + reg.add_argument("--pkg", help="Package name (default: filename)") + reg.add_argument("--state", default="held", help="Promotion state") + + # promote + pro = sub.add_parser("promote", parents=[common], help="Update promotion state") + pro.add_argument("pkg", help="Package name") + pro.add_argument("state", help="New state (held/semi/promoted)") + + # receipts + sub.add_parser("receipts", parents=[common], help="List receipts") + + # download + dl = sub.add_parser("download", parents=[common], help="Download package") + dl.add_argument("pkg", help="Package name") + dl.add_argument("--output", "-o", help="Output file") + + # delete + del_p = sub.add_parser("delete", parents=[common], help="Delete package") + del_p.add_argument("pkg", help="Package name") + + # push + push = sub.add_parser("push", parents=[common], help="Push to FTP reservoir") + push.add_argument("--ftp-host", default=os.environ.get("FTP_HOST", "ftpupload.net"), + help="FTP host") + push.add_argument("--ftp-user", default=os.environ.get("FTP_USER", "if0_42058601"), + help="FTP user") + push.add_argument("--ftp-pass", default=os.environ.get("FTP_PASS", ""), + help="FTP password") + + # pull + pull = sub.add_parser("pull", parents=[common], help="Pull from FTP reservoir") + pull.add_argument("--ftp-host", default=os.environ.get("FTP_HOST", "ftpupload.net")) + pull.add_argument("--ftp-user", default=os.environ.get("FTP_USER", "if0_42058601")) + pull.add_argument("--ftp-pass", default=os.environ.get("FTP_PASS", "")) + + # seed + seed = sub.add_parser("seed", parents=[common], help="Seed from directory") + seed.add_argument("src", help="Source directory") + + args = p.parse_args() + rv = LocalReservoir(Path(args.root)) + + cmds = { + "health": cmd_health, + "list": cmd_list, + "register": cmd_register, + "promote": cmd_promote, + "receipts": cmd_receipts, + "download": cmd_download, + "delete": cmd_delete, + "push": cmd_push, + "pull": cmd_pull, + "seed": cmd_seed, + } + cmds[args.cmd](rv, args) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/desi_model_projection_receipt_2026-05-13/unified_rg_receipt.json b/desi_model_projection_receipt_2026-05-13/unified_rg_receipt.json index eb8bf292..9c56c40a 100644 --- a/desi_model_projection_receipt_2026-05-13/unified_rg_receipt.json +++ b/desi_model_projection_receipt_2026-05-13/unified_rg_receipt.json @@ -1,6 +1,6 @@ { "schema": "unified_rg_test_suite_v2", - "generated_at": "2026-05-30T13:57:02Z", + "generated_at": "2026-05-30T18:10:40Z", "rg_fixed_point": 1.2618595071429148, "tests": [ { @@ -23,8 +23,8 @@ "D_rg": 1.2618595071429148, "D_infinite": 1.1965000000000001, "D_inf_CI": [ - 1.1484298439127854, - 1.2448633199949115 + 1.1481297537597908, + 1.243916266133916 ], "spectral_measured": 1.9, "note": "Non-monotonic data; spectral exponent favors standard" @@ -64,6 +64,40 @@ ], "note": "Curated data; selection bias possible; full range wider" } + }, + { + "name": "BraidSpherionBridge", + "result": { + "lean_file": "BraidSpherionBridge.lean", + "lake_build": "3560 jobs, 0 errors", + "theorems_proven": 7, + "admits_discharged": true, + "proof_type": "formal" + } + }, + { + "name": "Hexagonal lattice RG", + "result": { + "source": "Gao, Zhang, Chen (2026) \u2014 arXiv:2605.09974", + "model": "Hofstadter model, hexagonal lattice, NN hopping, irrational flux", + "transfer_matrix_size": "2x2", + "phase_diagram": { + "localized": "t2 < min(t1, 1)", + "critical": "t2 = min(t1, 1)", + "extended": "t2 > min(t1, 1)" + }, + "critical_exponent_nu": 1.0, + "fractal_dimensions": { + "extended_FD_inf": 1.0, + "critical_FD_inf": 0.5, + "localized_FD_inf": 0.0 + }, + "rg_confirms_localized": true, + "rg_confirms_extended_partial": true, + "no_mobility_edge": true, + "chiral_symmetry": true, + "note": "RG theory confirmed; complementary to D=log_3(4) spectral dimension" + } } ], "metrics": { @@ -107,6 +141,7 @@ "Sine-Gordon is prediction only with falsification criteria", "Boundary data is curated from 50 refs, selection bias possible", "Burgers data is non-monotonic, extrapolation unreliable", - "Spectral exponent favors standard (1.9 closer to 2.0 than 1.262)" + "Spectral exponent favors standard (1.9 closer to 2.0 than 1.262)", + "Hexagonal lattice test confirms RG theory for quasiperiodic systems (arXiv:2605.09974)" ] } \ No newline at end of file diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 00000000..5fca3f84 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file