diff --git a/playwright.config.ts b/playwright.config.ts index 2af01043..d9ef4f1e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,22 +2,17 @@ import type { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { webServer: { - command: 'npm run build && npm run preview', - port: 4173 - // url: 'http://scott.localhost:5173', - // reuseExistingServer: true, - // stderr: 'pipe', - // stdout: 'pipe', + command: 'npm run dev', + port: 5173, + reuseExistingServer: true, }, testDir: 'tests', testMatch: /(.+\.)?(test|spec)\.[jt]s/, reporter: 'list', use: { - // Collect trace when retrying the failed test. + baseURL: 'http://demo.localhost:5173', trace: 'on-first-retry' } - // grep: /@node_modules/, - // grepInverse: /@node_modules/, }; export default config; diff --git a/tests/v3_api_security.test.ts b/tests/v3_api_security.test.ts new file mode 100644 index 00000000..0fee8655 --- /dev/null +++ b/tests/v3_api_security.test.ts @@ -0,0 +1,299 @@ +import { test, expect } from '@playwright/test'; + +/** + * ==================================================================================== + * Aether V3 API Security & Header Integration Tests + * ==================================================================================== + * + * Purpose: + * This test suite verifies that the SvelteKit frontend correctly handles critical + * API security headers in a simulated real-world browser environment. It replaces + * previous, less reliable Node.js-based test scripts. + * + * Key Scenarios Verified: + * 1. **Bootstrap Handshake:** Ensures the application can boot by mocking the + * initial `site_domain/search` API call. + * 2. **Unauthenticated Bypass:** Confirms that pages making public "lookup" + * requests (e.g., for country lists) correctly attach the `x-no-account-id` + * bypass header. + * 3. **Account ID Scavenging:** Verifies that for standard data requests, the + * application correctly "scavenges" the `account_id` from `localStorage` + * and includes it in the `x-account-id` header, a crucial fix for + * Svelte 5 hydration race conditions. + * + * Core Test Strategy: + * - **Run Against Dev Server:** The tests are configured in `playwright.config.ts` + * to run against the live `npm run dev` server for maximum speed and to avoid + * slow build steps. + * - **Comprehensive Mocking:** All API endpoints under `oneskyit.com` are + * intercepted. This isolates the test from network dependencies and ensures + * consistent, predictable results. + * - **State Injection:** The `page.addInitScript` function is used to inject + * a complete, valid `ae_loc` object into the browser's `localStorage` + * *before* the Svelte app loads. This prevents component initialization errors + * and application crashes. + * - **Correct Wait Logic:** Tests use `page.waitForRequest` *before* navigating + * (`page.goto`). This is the correct pattern to avoid race conditions where a + * request might fire and be missed before the test starts listening for it. + */ + +// This is the ground truth for a new user's localStorage state. +// It is copied directly from `src/lib/stores/ae_stores.ts` (`ae_app_local_data_defaults`) +// to ensure the test browser's state perfectly matches the application's expectations, +// preventing crashes from undefined properties on the `$ae_loc` store. +const ae_app_local_data_defaults = { + last_page_reload: null, + last_cache_refresh: Date.now(), + cache_expired: false, + ver: '2025-05-01_1445', + ver_idb: '2025-05-01_1445', + name: 'Aether - App Hub (SvelteKit 2.x Svelte 4.x)', + theme: 'light', + theme_mode: 'light', + theme_name: 'nouveau', + iframe: false, + browser_type: null, + title: `OSIT's Æ`, + debug_mode: false, + edit_mode: false, + adv_mode: false, + sync_local_config: true, + account_id: null, + account_code: 'not_set', + account_name: 'Account Name Not Set', + allow_access: true, + site_domain: null, + site_access_key: null, + site_domain_access_key: null, + site_cfg_json: { + slct__event_id: null, + slct__event_badge_template_id: null, + slct__sponsorship_cfg_id: null, + header_image_path: null + }, + site_access_code_kv: { + administrator: null, + trusted: null, + public: 'public1980', + authenticated: 'auth1980' + }, + access_type: 'anonymous', + administrator_access: false, + trusted_access: false, + public_access: false, + authenticated_access: false, + anonymous_access: true, + user_email: null, + user_access_type: null, + jwt: null, + person_id: null, + person: { + id: null, + given_name: null, + full_name: null, + full_name_override: null, + primary_email: null, + user_id: null, + qry_limit__people: 150, + show_content__person_page_help: false + }, + user_id: null, + user: { + id: null, + username: null, + name: null, + email: null, + allow_auth_key: null, + super: false, + manager: false, + administrator: false, + verified: false, + public: false, + person_id: null, + access_type: null, + qry_limit__users: 100 + }, + qry__enabled: 'enabled', + qry__hidden: 'not_hidden', + qry__limit: 20, + qry__offset: 0, + qr_scanner_version: 'one', + admin: { + show_element__sql_qry: false, + show_element__sql_qry_results: false + }, + sys_menu: { + hide: false, + expand: false, + hide_access_type: false, + expand_access_type: false, + hide_edit_mode: false, + expand_edit_mode: false, + hide_user: false, + expand_user: false, + hide_theme: false, + expand_theme: false, + hide_app_cfg: false, + expand_app_cfg: false + }, + debug_menu: { + hide: false, + expand: false + }, + app_cfg: { + show_element__header: false, + show_element__footer: false, + show_element__menu: false, + show_element__menu_btn: true, + show_element__access_type: true, + show_element__passcode_input: true, + show_element__cfg: true, + show_element__cfg_detail: false, + show_element__sign_in_out: true, + show_opt__debug: true, + show_opt__permissions: true, + show_opt__reset: true, + show_opt__sync: true, + show_opt__theme: true, + show_opt__utilities: true + }, + files: { + processed_file_kv: {}, + uploaded_file_kv: {}, + video_clip_file_kv: {}, + add_to_use_files_method: 'upload' + }, + ds: {}, + hub: { + show_element__cfg: true, + show_element__cfg_detail: false, + show_element__access_type: true, + theme_mode: 'light', + theme_name: 'wintry', + classes__form: 'border border-surface-200 p-4 space-y-4 rounded-container', + qr: {} + }, + mod: { + archives: {}, + events: { + event_id: null, + show_edit__event_presenter_obj: false, + show_list__event_presenter_obj_li: true, + show_view__event_presenter_obj: false, + submit_status: null, + default_session_id: null + }, + journals: {}, + posts: {}, + sponsorships: { + cfg_id: 'XXXX', + for_type: null, + for_id: null, + level_guest_max_li: { + 0: 0, 1: 4, 2: 8, 3: 8, 4: 8, 5: 8, 6: 16, 7: 16 + }, + show_edit__sponsorship_obj: false, + show_list__sponsorship_obj_li: true, + show_view__sponsorship_obj: false, + show_question__accommodations: false, + submit_status: null + } + } +}; + +test.describe('V3 API Header Integrity', () => { + test.setTimeout(7000); + + test.beforeEach(async ({ page }) => { + // Log browser console errors to the terminal for easier debugging. + page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warn') { + console.error(`BROWSER [${msg.type().toUpperCase()}]: ${msg.text()}`); + } + }); + + // Mock all API requests to ensure tests are fast and independent of the network. + await page.route('**/*oneskyit.com/**', async (route) => { + const url = route.request().url(); + + // 1. Handshake Mock: Provide a complete response to allow the app to boot. + if (url.includes('site_domain/search')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { + id: 'test-site-domain-id', + id_random: 'test-site-domain-id', + account_id: 'test-account-id', + site_id: 'test-site-id', + account_name: 'Test Account', + enable: '1', + cfg_json: {} + } + ] + }) + }); + } + // 2. Default Mock: Provide a generic empty success response for all other API calls. + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [] }) + }); + }); + }); + + test('Verify lookup requests include the unauthenticated bypass header', async ({ page }) => { + // Prepare the browser's localStorage with the necessary state for this test. + await page.addInitScript((defaults) => { + const testData = { ...defaults, account_id: 'test-account-id', manager_access: true }; + window.localStorage.setItem('ae_loc', JSON.stringify(testData)); + }, ae_app_local_data_defaults); + + // Start waiting for the lookup request *before* navigating. + const requestPromise = page.waitForRequest((request) => + request.url().includes('/v3/lookup/country/list') + ); + + // Navigate to the page that triggers the lookup. + await page.goto('/core/lookups'); + + // Wait for the request to be captured. + const request = await requestPromise; + const headers = request.headers(); + + // Assert that the correct bypass headers were used. + expect(headers['x-no-account-id']).toBe('Nothing to See Here'); + expect(headers['x-aether-api-key']).toBeDefined(); + }); + + test('Verify Account ID Scavenging from localStorage on CRUD requests', async ({ page }) => { + const testAccountId = 'scavenged-account-id-123'; + + // Prepare the browser's localStorage with a specific account ID. + await page.addInitScript(({ defaults, id }) => { + const testData = { ...defaults, account_id: id, manager_access: true }; + window.localStorage.setItem('ae_loc', JSON.stringify(testData)); + },{ defaults: ae_app_local_data_defaults, id: testAccountId }); + + // Start waiting for the CRUD request. + const requestPromise = page.waitForRequest((request) => { + const url = request.url(); + // The /core/users page triggers a 'user' search on load. + return url.includes('/v3/crud/user/search'); + }); + + // Navigate to a page that is guaranteed to make a standard CRUD call. + await page.goto('/core/users'); + + // Wait for the request to be captured. + const request = await requestPromise; + const headers = request.headers(); + + // Assert that the scavenged account ID was correctly included in the header. + expect(headers['x-account-id']).toBe(testAccountId); + }); +}); diff --git a/tests/verify_jwt_logic.js b/tests/verify_jwt_logic.js deleted file mode 100644 index 2f4f87fd..00000000 --- a/tests/verify_jwt_logic.js +++ /dev/null @@ -1,65 +0,0 @@ - -// @ts-nocheck -const api_cfg_missing_jwt = { - headers: { - 'x-aether-api-key': 'secret-key', - } -}; - -const api_cfg_with_jwt = { - headers: { - 'x-aether-api-key': 'secret-key', - }, - jwt: 'valid-jwt-token' -}; - -const api_cfg_with_header_jwt = { - headers: { - 'x-aether-api-key': 'secret-key', - 'jwt': 'valid-jwt-token-in-header' - } -}; - -function simulate_get_object(api_cfg, headers = {}) { - // Logic from api_get_object.ts - const headers_cleaned = {}; - const merged_headers = { ...api_cfg['headers'], ...headers }; - - for (const prop in merged_headers) { - const prop_cleaned = prop.replaceAll('_', '-'); - let value = merged_headers[prop]; - if (value === null || value === undefined) continue; - headers_cleaned[prop_cleaned] = value; - } - - const jwt = headers_cleaned['jwt'] || headers_cleaned['JWT'] || api_cfg['jwt']; - if (jwt && !headers_cleaned['Authorization'] && !headers_cleaned['authorization']) { - headers_cleaned['Authorization'] = `Bearer ${jwt}`; - } - - return headers_cleaned; -} - -console.log("--- Test 1: Missing JWT in Config ---"); -const headers1 = simulate_get_object(api_cfg_missing_jwt); -if (headers1['Authorization']) { - console.error("FAIL: Authorization header present when it should be missing."); -} else { - console.log("PASS: Authorization header missing as expected."); -} - -console.log("\n--- Test 2: JWT in Config Root ---"); -const headers2 = simulate_get_object(api_cfg_with_jwt); -if (headers2['Authorization'] === 'Bearer valid-jwt-token') { - console.log("PASS: Authorization header present and correct."); -} else { - console.error(`FAIL: Authorization header incorrect or missing. Got: ${headers2['Authorization']}`); -} - -console.log("\n--- Test 3: JWT in Config Headers ---"); -const headers3 = simulate_get_object(api_cfg_with_header_jwt); -if (headers3['Authorization'] === 'Bearer valid-jwt-token-in-header') { - console.log("PASS: Authorization header present and correct."); -} else { - console.error(`FAIL: Authorization header incorrect or missing. Got: ${headers3['Authorization']}`); -} diff --git a/tests/verify_jwt_sync.js b/tests/verify_jwt_sync.js deleted file mode 100644 index 1d47d588..00000000 --- a/tests/verify_jwt_sync.js +++ /dev/null @@ -1,25 +0,0 @@ - -// @ts-nocheck -let ae_loc_mock = { jwt: 'valid-jwt-token' }; -let ae_api_mock = { headers: {} }; - -function simulate_effect() { - if (ae_api_mock.jwt !== ae_loc_mock.jwt) { - console.log('Syncing JWT to API config'); - ae_api_mock = { - ...ae_api_mock, - jwt: ae_loc_mock.jwt - }; - } -} - -console.log("--- Test: Sync JWT Effect ---"); -console.log("Before:", ae_api_mock); -simulate_effect(); -console.log("After:", ae_api_mock); - -if (ae_api_mock.jwt === 'valid-jwt-token') { - console.log("PASS: JWT synced correctly."); -} else { - console.error("FAIL: JWT not synced."); -}