diff --git a/src/handler/http/request/domain_management/mod.rs b/src/handler/http/request/domain_management/mod.rs new file mode 100644 index 0000000000..00cae3bba0 --- /dev/null +++ b/src/handler/http/request/domain_management/mod.rs @@ -0,0 +1,114 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::io::Error; + +use actix_web::{HttpResponse, get, put, web}; +use config::META_ORG_ID; +#[cfg(feature = "enterprise")] +use o2_enterprise::enterprise::domain_management::{self, meta::DomainManagementRequest}; + +use crate::common::meta::http::HttpResponse as MetaHttpResponse; + +/// Helper function to validate that only meta org can access domain management APIs +fn validate_meta_org_access(org_id: &str) -> Result<(), infra::errors::Error> { + if org_id != META_ORG_ID { + return Err(infra::errors::Error::Message(format!( + "Domain management APIs are only available for meta organization. Provided org_id: {org_id}, expected: {META_ORG_ID}" + ))); + } + Ok(()) +} + +/// Get domain management configuration +#[utoipa::path( + context_path = "/api", + tag = "Domain Management", + operation_id = "GetDomainManagementConfig", + security(("Authorization" = [])), + params( + ("org_id" = String, Path, description = "Organization name (must be meta org)"), + ), + responses( + (status = 200, description = "Success", content_type = "application/json", body = DomainManagementResponse), + (status = 403, description = "Forbidden", content_type = "application/json", body = HttpResponse), + (status = 404, description = "Not Found", content_type = "application/json", body = HttpResponse), + (status = 500, description = "Internal Server Error", content_type = "application/json", body = HttpResponse), + ) +)] +#[get("/{org_id}/domain_management")] +pub async fn get_domain_management_config(path: web::Path) -> Result { + let org_id = path.into_inner(); + + // Validate that only meta org can access domain management APIs + if let Err(e) = validate_meta_org_access(&org_id) { + return Ok(MetaHttpResponse::forbidden(e)); + } + + match domain_management::get_domain_management_config().await { + Ok(response) => Ok(MetaHttpResponse::json(response)), + Err(e) => { + log::error!("Error getting domain management config: {}", e); + match e { + infra::errors::Error::Message(ref msg) if msg.contains("not found") => { + Ok(MetaHttpResponse::not_found(e)) + } + _ => Ok(MetaHttpResponse::internal_error(e)), + } + } + } +} + +/// Set domain management configuration +#[utoipa::path( + context_path = "/api", + tag = "Domain Management", + operation_id = "SetDomainManagementConfig", + security(("Authorization" = [])), + params( + ("org_id" = String, Path, description = "Organization name (must be meta org)"), + ), + request_body(content = DomainManagementRequest, description = "Domain management configuration", content_type = "application/json"), + responses( + (status = 200, description = "Success", content_type = "application/json", body = DomainOperationResponse), + (status = 400, description = "Bad Request", content_type = "application/json", body = HttpResponse), + (status = 403, description = "Forbidden", content_type = "application/json", body = HttpResponse), + (status = 500, description = "Internal Server Error", content_type = "application/json", body = HttpResponse), + ) +)] +#[put("/{org_id}/domain_management")] +pub async fn set_domain_management_config( + path: web::Path, + body: web::Json, +) -> Result { + let org_id = path.into_inner(); + let request = body.into_inner(); + + // Validate that only meta org can access domain management APIs + if let Err(e) = validate_meta_org_access(&org_id) { + return Ok(MetaHttpResponse::forbidden(e)); + } + + match domain_management::set_domain_management_config(request).await { + Ok(response) => Ok(MetaHttpResponse::json(response)), + Err(e) => { + log::error!("Error setting domain management config: {}", e); + match e { + infra::errors::Error::Message(_) => Ok(MetaHttpResponse::bad_request(e)), + _ => Ok(MetaHttpResponse::internal_error(e)), + } + } + } +} diff --git a/src/handler/http/request/mod.rs b/src/handler/http/request/mod.rs index 7f7fb47182..ec02eb77ec 100644 --- a/src/handler/http/request/mod.rs +++ b/src/handler/http/request/mod.rs @@ -22,6 +22,8 @@ pub mod authz; pub mod cloud; pub mod clusters; pub mod dashboards; +#[cfg(feature = "enterprise")] +pub mod domain_management; pub mod enrichment_table; #[allow(deprecated)] pub mod folders; diff --git a/src/handler/http/request/status/mod.rs b/src/handler/http/request/status/mod.rs index f581a844d4..e29f809f0c 100644 --- a/src/handler/http/request/status/mod.rs +++ b/src/handler/http/request/status/mod.rs @@ -559,17 +559,27 @@ pub async fn redirect(req: HttpRequest) -> Result { .json("Service accounts are not allowed to login".to_string())); } - // get domain from email - let domain = res.0.user_email.split('@').nth(1).unwrap_or_default(); - if !get_dex_config().allowed_domains.is_empty() - && !get_dex_config().allowed_domains.contains(domain) + // Check if email is allowed by domain management system + #[cfg(feature = "enterprise")] + match o2_enterprise::enterprise::domain_management::is_email_allowed( + &res.0.user_email, + ) + .await { - audit_message.response_meta.http_response_code = 400; - audit_message._timestamp = now_micros(); - audit(audit_message).await; - return Ok( - HttpResponse::Unauthorized().json("Unauthorized access".to_string()) - ); + Ok(allowed) => { + if !allowed { + audit_message.response_meta.http_response_code = 403; + audit_message._timestamp = now_micros(); + audit(audit_message).await; + return Ok(HttpResponse::Unauthorized() + .json("Unauthorized".to_string())); + } + } + Err(e) => { + log::error!("Failed to check domain management: {}", e); + // Fail open - allow access if domain management check fails + // This prevents system lockouts due to configuration errors + } } audit_message.user_email = res.0.user_email.clone(); diff --git a/src/handler/http/router/mod.rs b/src/handler/http/router/mod.rs index 7becedc02a..12bfc64c1f 100644 --- a/src/handler/http/router/mod.rs +++ b/src/handler/http/router/mod.rs @@ -587,7 +587,9 @@ pub fn get_service_routes(svc: &mut web::ServiceConfig) { .service(ratelimit::api_modules) .service(actions::operations::test_action) .service(ai::chat) - .service(ai::chat_stream); + .service(ai::chat_stream) + .service(domain_management::get_domain_management_config) + .service(domain_management::set_domain_management_config); #[cfg(feature = "cloud")] let service = service diff --git a/src/job/mod.rs b/src/job/mod.rs index fd2f1ae404..f33ead73fe 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -164,15 +164,19 @@ pub async fn init() -> Result<(), anyhow::Error> { .expect("short url cache failed"); // initialize metadata watcher - tokio::task::spawn(db::schema::watch()); - tokio::task::spawn(db::functions::watch()); - tokio::task::spawn(db::compact::retention::watch()); - tokio::task::spawn(db::metrics::watch_prom_cluster_leader()); - tokio::task::spawn(db::alerts::templates::watch()); - tokio::task::spawn(db::alerts::destinations::watch()); - tokio::task::spawn(db::alerts::realtime_triggers::watch()); - tokio::task::spawn(db::alerts::alert::watch()); - tokio::task::spawn(db::organization::org_settings_watch()); + tokio::task::spawn(async move { db::schema::watch().await }); + tokio::task::spawn(async move { db::functions::watch().await }); + tokio::task::spawn(async move { db::compact::retention::watch().await }); + tokio::task::spawn(async move { db::metrics::watch_prom_cluster_leader().await }); + tokio::task::spawn(async move { db::alerts::templates::watch().await }); + tokio::task::spawn(async move { db::alerts::destinations::watch().await }); + tokio::task::spawn(async move { db::alerts::realtime_triggers::watch().await }); + tokio::task::spawn(async move { db::alerts::alert::watch().await }); + tokio::task::spawn(async move { db::organization::org_settings_watch().await }); + #[cfg(feature = "enterprise")] + tokio::task::spawn( + async move { o2_enterprise::enterprise::domain_management::db::watch().await }, + ); // pipeline not used on compactors if LOCAL_NODE.is_ingester() || LOCAL_NODE.is_querier() || LOCAL_NODE.is_alert_manager() { @@ -218,6 +222,10 @@ pub async fn init() -> Result<(), anyhow::Error> { db::syslog::cache_syslog_settings() .await .expect("syslog settings cache failed"); + #[cfg(feature = "enterprise")] + o2_enterprise::enterprise::domain_management::db::cache() + .await + .expect("domain management cache failed"); infra_file_list::create_table_index().await?; infra_file_list::LOCAL_CACHE.create_table_index().await?; diff --git a/src/super_cluster_queue/domain_management.rs b/src/super_cluster_queue/domain_management.rs new file mode 100644 index 0000000000..6cf01efed5 --- /dev/null +++ b/src/super_cluster_queue/domain_management.rs @@ -0,0 +1,43 @@ +// Copyright 2024 Zinc Labs Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use infra::errors::{Error, Result}; +use o2_enterprise::enterprise::{ + super_cluster::queue::{Message, MessageType} +}; + +pub(crate) async fn process(msg: Message) -> Result<()> { + let db = infra::db::get_db().await; + log::info!("[SUPER_CLUSTER:DB] domain management update received"); + match msg.message_type { + MessageType::Put => { + db.put(&msg.key, msg.value.unwrap(), msg.need_watch, None) + .await?; + } + MessageType::Delete(with_prefix) => { + db.delete(&msg.key, with_prefix, msg.need_watch, None) + .await?; + } + _ => { + log::error!( + "[SUPER_CLUSTER:DB] Invalid message: type: {:?}, key: {}", + msg.message_type, + msg.key + ); + return Err(Error::Message("Invalid message type".to_string())); + } + } + Ok(()) +} \ No newline at end of file diff --git a/src/super_cluster_queue/mod.rs b/src/super_cluster_queue/mod.rs index cf07613e86..f1a1caa046 100644 --- a/src/super_cluster_queue/mod.rs +++ b/src/super_cluster_queue/mod.rs @@ -30,6 +30,7 @@ mod scheduler; mod schemas; mod search_job; mod short_urls; +mod domain_management; mod templates; mod user; @@ -55,6 +56,7 @@ pub async fn init() -> Result<(), anyhow::Error> { on_pipeline_msg: pipelines::process, on_cipher_key_msg: cipher_keys::process, on_rate_limit_msg: ratelimit::process, + on_domain_management_msg: domain_management::process, }; let schema_queue = SchemasQueue { on_schema_msg: schemas::process, diff --git a/web/src/components/settings/DomainManagement.integration.spec.ts b/web/src/components/settings/DomainManagement.integration.spec.ts new file mode 100644 index 0000000000..e84182f9a2 --- /dev/null +++ b/web/src/components/settings/DomainManagement.integration.spec.ts @@ -0,0 +1,711 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; +import { flushPromises, mount } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import DomainManagement from "./DomainManagement.vue"; +import i18n from "@/locales"; +import { Dialog, Notify } from "quasar"; +import { nextTick } from "vue"; +import { + mockDomainData, + createMockStore, + domainValidationTestCases, + emailValidationTestCases, + apiErrorScenarios, +} from "./DomainManagement.test-helpers"; + +installQuasar({ + plugins: [Dialog, Notify], +}); + +// Create a unique DOM node for this test file to avoid conflicts +const uniqueNodeId = "domain-management-test-app"; +const node = document.createElement("div"); +node.setAttribute("id", uniqueNodeId); +document.body.appendChild(node); + +// Mock the domainManagement service with a unique namespace +vi.mock("@/services/domainManagement", () => ({ + default: { + getDomainRestrictions: vi.fn(), + updateDomainRestrictions: vi.fn(), + }, +})); + +// Import after mocking +import domainManagement from "@/services/domainManagement"; +const mockDomainManagement = domainManagement as any; + +// Mock Vuex store with isolated scope +const createIsolatedMockStore = () => ({ + state: { + zoConfig: { + meta_org: "test-meta-org", + }, + selectedOrganization: { + identifier: "test-meta-org", + }, + }, +}); + +const mockStore = createIsolatedMockStore(); + +vi.mock("vuex", () => ({ + useStore: () => mockStore, +})); + +// Mock Vue Router with isolated scope +const createIsolatedMockRouter = () => ({ + replace: vi.fn(), +}); + +const mockRouter = createIsolatedMockRouter(); + +vi.mock("vue-router", () => ({ + useRouter: () => mockRouter, +})); + +describe("DomainManagement Integration Tests", () => { + let wrapper: any = null; + + beforeEach(() => { + // Clear all mocks and reset state + vi.clearAllMocks(); + vi.resetAllMocks(); + + // Reset mock implementations + mockDomainManagement.getDomainRestrictions.mockResolvedValue({ + data: mockDomainData.complex, + }); + mockDomainManagement.updateDomainRestrictions.mockResolvedValue({ + success: true, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + wrapper = null; + } + // Thorough cleanup + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.resetAllMocks(); + + // Clear any leftover DOM elements + const testNode = document.getElementById(uniqueNodeId); + if (testNode) { + testNode.innerHTML = ''; + } + }); + + afterAll(() => { + // Final cleanup after all tests + const testNode = document.getElementById(uniqueNodeId); + if (testNode) { + document.body.removeChild(testNode); + } + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + const createWrapper = (props = {}) => { + return mount(DomainManagement, { + attachTo: `#${uniqueNodeId}`, + shallow: false, + props, + global: { + plugins: [i18n], + }, + }); + }; + + describe("End-to-End Domain Management Workflow", () => { + it("should complete full domain management workflow", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // 1. Load initial data + expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledWith( + "test-meta-org" + ); + expect(vm.domains.length).toBe(3); // From mockDomainData.complex + + // 2. Add a new domain + vm.newDomain = "newcompany.com"; + await vm.addDomain(); + expect(vm.domains.length).toBe(4); + expect(vm.domains[3].name).toBe("newcompany.com"); + expect(vm.domains[3].allowAllUsers).toBe(true); + + // 3. Modify domain to restrict users + vm.domains[3].allowAllUsers = false; + + // 4. Add emails to restricted domain + vm.domains[3].newEmail = "admin@newcompany.com"; + await vm.addEmail(vm.domains[3]); + expect(vm.domains[3].allowedEmails).toContain("admin@newcompany.com"); + + vm.domains[3].newEmail = "user@newcompany.com"; + await vm.addEmail(vm.domains[3]); + expect(vm.domains[3].allowedEmails).toContain("user@newcompany.com"); + expect(vm.domains[3].allowedEmails.length).toBe(2); + + // 5. Save changes + await vm.saveChanges(); + + expect(mockDomainManagement.updateDomainRestrictions).toHaveBeenCalledWith( + "test-meta-org", + expect.objectContaining({ + domains: expect.arrayContaining([ + expect.objectContaining({ + domain: "newcompany.com", + allow_all_users: false, + allowed_emails: ["admin@newcompany.com", "user@newcompany.com"], + }), + ]), + }) + ); + + // 6. Verify success emission + expect(wrapper.emitted().saved).toBeTruthy(); + }); + + it("should handle domain removal workflow", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + const initialCount = vm.domains.length; + + // Mock the q.dialog method directly on the component + const mockDialog = vi.fn().mockReturnValue({ + onOk: vi.fn((callback) => { + callback(); // Immediately call the callback to simulate OK click + return { onCancel: vi.fn() }; + }), + }); + + // Add $q to the component instance if it doesn't exist + if (!vm.$q) { + vm.$q = { dialog: mockDialog }; + } else { + vm.$q.dialog = mockDialog; + } + + // Call the actual removeDomain method + await vm.removeDomain(0); + + expect(vm.domains.length).toBe(initialCount - 1); + }); + + it("should handle email removal workflow", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + const restrictedDomain = vm.domains.find(d => !d.allowAllUsers); + + if (restrictedDomain) { + const initialEmailCount = restrictedDomain.allowedEmails.length; + + // Remove an email directly (simulating confirmed dialog) + restrictedDomain.allowedEmails.splice(0, 1); + + expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount - 1); + } + }); + }); + + describe("Validation Test Cases", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + describe("Domain Validation", () => { + domainValidationTestCases.forEach(({ input, expected, description }) => { + it(`should ${expected ? 'accept' : 'reject'} ${description}: "${input}"`, () => { + const vm = wrapper.vm; + expect(vm.isValidDomain(input)).toBe(expected); + }); + }); + }); + + describe("Email Validation", () => { + emailValidationTestCases.forEach(({ email, domain, expected, description }) => { + it(`should ${expected ? 'accept' : 'reject'} ${description}`, () => { + const vm = wrapper.vm; + expect(vm.isValidEmail(email, domain)).toBe(expected); + }); + }); + }); + }); + + describe("Error Handling Scenarios", () => { + apiErrorScenarios.forEach(({ name, error, expectedMessage }) => { + it(`should handle ${name} gracefully`, async () => { + mockDomainManagement.getDomainRestrictions.mockRejectedValue(error); + + wrapper = createWrapper(); + await flushPromises(); + + // Component should still mount despite error + expect(wrapper.exists()).toBeTruthy(); + expect(wrapper.vm.domains.length).toBe(0); // Should show empty state + }); + + it(`should handle ${name} during save operations`, async () => { + wrapper = createWrapper(); + await flushPromises(); + + mockDomainManagement.updateDomainRestrictions.mockRejectedValue(error); + + const vm = wrapper.vm; + await vm.saveChanges(); + + expect(vm.saving).toBe(false); + }); + }); + }); + + describe("Data Persistence and State Management", () => { + it("should maintain state consistency during operations", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + const initialState = JSON.parse(JSON.stringify(vm.domains)); + + // Add domain + vm.newDomain = "test.com"; + await vm.addDomain(); + + // Verify state change + expect(vm.domains.length).toBe(initialState.length + 1); + + // Reset form + await vm.resetForm(); + + // Should reload original data + expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledTimes(2); + }); + + it("should handle concurrent operations gracefully", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // Simulate concurrent save operations + const savePromise1 = vm.saveChanges(); + const savePromise2 = vm.saveChanges(); + + await Promise.all([savePromise1, savePromise2]); + + // Should maintain consistent state + expect(vm.saving).toBe(false); + }); + }); + + describe("User Interface Interactions", () => { + it("should update UI elements correctly based on data", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // Check domain count display + const countText = wrapper.find(".text-caption"); + expect(countText.text()).toContain(vm.domains.length.toString()); + + // Add a domain and check count update + vm.newDomain = "test.com"; + await vm.addDomain(); + await nextTick(); + + const updatedCountText = wrapper.find(".text-caption"); + expect(updatedCountText.text()).toContain(vm.domains.length.toString()); + }); + + it("should enable/disable buttons based on validation", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // Test add domain button state + vm.newDomain = ""; + await nextTick(); + + // Try to find the add domain button with a simpler selector + const addDomainButton = wrapper.find('button'); + const buttons = wrapper.findAll('button'); + + // Look for button with "Add Domain" text + let addButton = null; + for (let i = 0; i < buttons.length; i++) { + if (buttons[i].text().includes('Add Domain')) { + addButton = buttons[i]; + break; + } + } + + if (addButton) { + expect(addButton.element.disabled).toBe(true); + } + + // Set valid domain + vm.newDomain = "valid.com"; + await nextTick(); + + if (addButton) { + expect(addButton.element.disabled).toBe(false); + } + }); + }); + + describe("Component Lifecycle", () => { + it("should handle component activation correctly", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Simulate component activation (router navigation) + if (wrapper.vm.$options.activated) { + await wrapper.vm.$options.activated[0].call(wrapper.vm); + } + + // Should reload data on activation + expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalled(); + }); + + it("should clean up properly on unmount", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // Start a save operation + const savePromise = vm.saveChanges(); + + // Unmount component + wrapper.unmount(); + + // Save operation should complete without errors + await expect(savePromise).resolves.not.toThrow(); + }); + }); + + describe("Comprehensive Negative Testing", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + describe("Input Validation Edge Cases", () => { + it("should reject malicious script injection in domain names", async () => { + const vm = wrapper.vm; + const maliciousDomains = [ + ".com", + "javascript:alert(1).com", + "../../etc/passwd.com", + "null.com\0", + "test.com", + ]; + + for (const maliciousDomain of maliciousDomains) { + expect(vm.isValidDomain(maliciousDomain)).toBe(false); + } + }); + + it("should reject malicious script injection in email addresses", async () => { + const vm = wrapper.vm; + const maliciousEmails = [ + "@example.com", + "javascript:alert(1)@example.com", + "test@.com", + "user@example.com", + "test\0@example.com", + ]; + + for (const maliciousEmail of maliciousEmails) { + expect(vm.isValidEmail(maliciousEmail, "example.com")).toBe(false); + } + }); + + it("should reject extremely long domain names", async () => { + const vm = wrapper.vm; + const longDomain = "a".repeat(250) + ".com"; // Domain names should be max 253 chars + const veryLongDomain = "a".repeat(300) + ".com"; + + expect(vm.isValidDomain(longDomain)).toBe(false); + expect(vm.isValidDomain(veryLongDomain)).toBe(false); + }); + + it("should reject extremely long email addresses", async () => { + const vm = wrapper.vm; + const longEmail = "a".repeat(300) + "@example.com"; // Email addresses have practical limits + + expect(vm.isValidEmail(longEmail, "example.com")).toBe(false); + }); + + it("should reject Unicode and special character exploits", async () => { + const vm = wrapper.vm; + const unicodeExploits = [ + "еxample.com", // Cyrillic 'e' that looks like Latin 'e' + "example․com", // Unicode one-dot leader instead of period + "example.com\u202e", // Right-to-left override + "example\u0000.com", // null byte + "example\u000A.com", // line feed + ]; + + for (const unicodeDomain of unicodeExploits) { + expect(vm.isValidDomain(unicodeDomain)).toBe(false); + } + }); + }); + + describe("Boundary Testing", () => { + it("should handle maximum number of domains gracefully", async () => { + const vm = wrapper.vm; + + // Add many domains to test performance and limits + for (let i = 0; i < 100; i++) { + vm.domains.push({ + name: `domain${i}.com`, + allowAllUsers: true, + allowedEmails: [] + }); + } + + // Should still function properly + expect(vm.domains.length).toBe(103); // 3 from mock + 100 added + + // Save operation should handle large payload + await vm.saveChanges(); + expect(mockDomainManagement.updateDomainRestrictions).toHaveBeenCalled(); + }); + + it("should handle maximum number of emails per domain", async () => { + const vm = wrapper.vm; + + // Add a domain with many emails + const testDomain = { + name: "test.com", + allowAllUsers: false, + allowedEmails: [], + newEmail: "" + }; + + vm.domains.push(testDomain); + + // Add many emails + for (let i = 0; i < 50; i++) { + testDomain.newEmail = `user${i}@test.com`; + await vm.addEmail(testDomain); + } + + expect(testDomain.allowedEmails.length).toBe(50); + }); + + it("should handle empty and whitespace-only inputs", async () => { + const vm = wrapper.vm; + + // Test empty domain + vm.newDomain = ""; + expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid (optional) + + // Test whitespace-only domain + vm.newDomain = " "; + expect(vm.isValidDomain(" ")).toBe(false); + + // Test tabs and newlines + expect(vm.isValidDomain("\t\n")).toBe(false); + expect(vm.isValidEmail(" ", "example.com")).toBe(false); + }); + }); + + describe("Concurrency and Race Condition Testing", () => { + it("should handle rapid consecutive operations", async () => { + const vm = wrapper.vm; + + // Rapid domain additions + const addPromises = []; + for (let i = 0; i < 5; i++) { + vm.newDomain = `rapid${i}.com`; + addPromises.push(vm.addDomain()); + } + + await Promise.all(addPromises); + + // Should handle without crashing + expect(vm.domains.length).toBeGreaterThan(3); // Original 3 + some new ones + }); + + it("should handle save operations during form modifications", async () => { + const vm = wrapper.vm; + + // Start save operation + const savePromise = vm.saveChanges(); + + // Modify data while saving + vm.newDomain = "concurrent.com"; + vm.addDomain(); + + // Should complete without errors + await expect(savePromise).resolves.not.toThrow(); + }); + + it("should handle multiple simultaneous save attempts", async () => { + const vm = wrapper.vm; + + // Start multiple save operations simultaneously + const savePromises = [ + vm.saveChanges(), + vm.saveChanges(), + vm.saveChanges() + ]; + + await Promise.all(savePromises); + + // Should maintain consistent state + expect(vm.saving).toBe(false); + }); + }); + + describe("State Corruption Prevention", () => { + it("should prevent invalid state mutations", async () => { + const vm = wrapper.vm; + + // Try to corrupt the domains array + const originalLength = vm.domains.length; + + try { + // Attempt various state corruptions + vm.domains.push(null); + vm.domains.push(undefined); + vm.domains.push("invalid"); + vm.domains.push({ invalidStructure: true }); + } catch (error) { + // Errors are expected for invalid mutations + } + + // Component should still function + await vm.saveChanges(); + expect(vm.saving).toBe(false); + }); + + it("should handle API responses with invalid structure", async () => { + mockDomainManagement.getDomainRestrictions.mockResolvedValue({ + data: { + domains: [ + null, // Invalid domain + undefined, // Invalid domain + "string", // Invalid domain + { invalid: true }, // Missing required fields + { + domain: "valid.com", + allow_all_users: "not_boolean", // Invalid type + allowed_emails: "not_array" // Invalid type + } + ] + } + }); + + wrapper = createWrapper(); + await flushPromises(); + + // Component should handle gracefully + expect(wrapper.exists()).toBeTruthy(); + }); + }); + + describe("Memory and Resource Testing", () => { + it("should handle component unmounting during operations", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // Start operations + vm.newDomain = "test.com"; + const addPromise = vm.addDomain(); + const savePromise = vm.saveChanges(); + + // Unmount component immediately + wrapper.unmount(); + + // Operations should complete without memory leaks + await expect(Promise.all([addPromise, savePromise])).resolves.not.toThrow(); + }); + + it("should clean up event listeners and timers", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // Trigger various operations to create potential listeners + vm.newDomain = "test.com"; + await vm.addDomain(); + await vm.saveChanges(); + + // Store a reference to test that the component exists + expect(wrapper.exists()).toBe(true); + + // Unmount should clean up properly without throwing errors + expect(() => wrapper.unmount()).not.toThrow(); + + // Component should no longer exist after unmounting + expect(wrapper.exists()).toBe(false); + }); + }); + + describe("Accessibility and Error Handling", () => { + it("should handle keyboard navigation edge cases", async () => { + const vm = wrapper.vm; + + // Test with invalid focus targets + try { + // Simulate keyboard events on non-existent elements + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + vm.$el?.dispatchEvent?.(event); + } catch (error) { + // Should not crash the component + } + + expect(wrapper.exists()).toBeTruthy(); + }); + + it("should maintain form state during validation errors", async () => { + const vm = wrapper.vm; + + // Set up form data + vm.newDomain = "invalid domain with spaces"; + + // Try to add invalid domain + await vm.addDomain(); + + // Form should maintain state and not be corrupted + expect(vm.newDomain).toBe("invalid domain with spaces"); + expect(vm.domains.length).toBe(3); // Should not have been added + }); + }); + }); +}); diff --git a/web/src/components/settings/DomainManagement.spec.ts b/web/src/components/settings/DomainManagement.spec.ts new file mode 100644 index 0000000000..81ec8356cd --- /dev/null +++ b/web/src/components/settings/DomainManagement.spec.ts @@ -0,0 +1,774 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { DOMWrapper, flushPromises, mount } from "@vue/test-utils"; +import { describe, expect, it, beforeEach, vi, afterEach, afterAll } from "vitest"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import DomainManagement from "./DomainManagement.vue"; +import i18n from "@/locales"; +import { Dialog, Notify } from "quasar"; +import { nextTick } from "vue"; + +installQuasar({ + plugins: [Dialog, Notify], +}); + +// Create a unique DOM node for this test file +const uniqueNodeId = "domain-management-unit-test-app"; +const node = document.createElement("div"); +node.setAttribute("id", uniqueNodeId); +document.body.appendChild(node); + +// Mock the domainManagement service +vi.mock("@/services/domainManagement", () => ({ + default: { + getDomainRestrictions: vi.fn(), + updateDomainRestrictions: vi.fn(), + }, +})); + +// Import the mocked service +import domainManagement from "@/services/domainManagement"; +const mockDomainManagement = domainManagement as any; + +// Mock Vuex store +const mockStore = { + state: { + zoConfig: { + meta_org: "test-meta-org", + }, + selectedOrganization: { + identifier: "test-meta-org", + }, + }, +}; + +vi.mock("vuex", () => ({ + useStore: () => mockStore, +})); + +// Mock Vue Router +const mockRouter = { + replace: vi.fn(), +}; + +vi.mock("vue-router", () => ({ + useRouter: () => mockRouter, +})); + +describe("DomainManagement Component", () => { + let wrapper: any = null; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + + // Mock successful API response by default + mockDomainManagement.getDomainRestrictions.mockResolvedValue({ + data: { + domains: [ + { + domain: "example.com", + allow_all_users: true, + allowed_emails: [], + }, + { + domain: "company.org", + allow_all_users: false, + allowed_emails: ["admin@company.org", "user@company.org"], + }, + ], + }, + }); + + mockDomainManagement.updateDomainRestrictions.mockResolvedValue({ + success: true, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + wrapper = null; + } + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.resetAllMocks(); + + // Clear any leftover DOM elements + const testNode = document.getElementById(uniqueNodeId); + if (testNode) { + testNode.innerHTML = ''; + } + }); + + afterAll(() => { + // Final cleanup after all tests + const testNode = document.getElementById(uniqueNodeId); + if (testNode) { + document.body.removeChild(testNode); + } + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + const createWrapper = (props = {}) => { + return mount(DomainManagement, { + attachTo: `#${uniqueNodeId}`, + shallow: false, + props, + global: { + plugins: [i18n], + }, + }); + }; + + describe("Component Mounting and Initial State", () => { + it("should mount DomainManagement component successfully", async () => { + wrapper = createWrapper(); + + expect(wrapper.exists()).toBeTruthy(); + expect(wrapper.find(".domain_management").exists()).toBeTruthy(); + }); + + it("should display correct title and description", async () => { + wrapper = createWrapper(); + + const title = wrapper.find(".text-h6"); + const description = wrapper.find(".text-body2"); + + expect(title.text()).toContain("SSO Domain Restrictions"); + expect(description.text()).toContain("Control which domains"); + }); + + it("should load domain settings on mount", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledWith( + "test-meta-org" + ); + }); + + it("should redirect if not meta organization", async () => { + mockStore.state.selectedOrganization.identifier = "different-org"; + + wrapper = createWrapper(); + await flushPromises(); + + expect(mockRouter.replace).toHaveBeenCalledWith({ + name: "general", + query: { + org_identifier: "different-org", + }, + }); + }); + }); + + describe("Domain Validation", () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it("should validate domain names correctly", async () => { + const vm = wrapper.vm; + + // Valid domains + expect(vm.isValidDomain("example.com")).toBe(true); + expect(vm.isValidDomain("sub.example.com")).toBe(true); + expect(vm.isValidDomain("my-company.org")).toBe(true); + + // Invalid domains + expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid for optional validation + expect(vm.isValidDomain("invalid")).toBe(false); + expect(vm.isValidDomain("spaces in domain.com")).toBe(false); + expect(vm.isValidDomain(".example.com")).toBe(false); + }); + + it("should validate email addresses for specific domains", async () => { + const vm = wrapper.vm; + + // Valid emails for domain + expect(vm.isValidEmail("user@example.com", "example.com")).toBe(true); + expect(vm.isValidEmail("admin@company.org", "company.org")).toBe(true); + + // Invalid emails + expect(vm.isValidEmail("", "example.com")).toBe(false); + expect(vm.isValidEmail("invalid-email", "example.com")).toBe(false); + expect(vm.isValidEmail("user@different.com", "example.com")).toBe(false); + expect(vm.isValidEmail("user@example.com", "different.org")).toBe(false); + }); + }); + + describe("Adding Domains", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + it("should add a new domain successfully", async () => { + const vm = wrapper.vm; + + vm.newDomain = "newdomain.com"; + await vm.addDomain(); + await nextTick(); + + expect(vm.domains).toContainEqual( + expect.objectContaining({ + name: "newdomain.com", + allowAllUsers: true, + allowedEmails: [], + }) + ); + }); + + it("should not add invalid domain", async () => { + const vm = wrapper.vm; + + vm.newDomain = "invalid-domain"; + + expect(vm.isValidDomain("invalid-domain")).toBe(false); + }); + + it("should not add duplicate domain", async () => { + const vm = wrapper.vm; + + // Manually add some test data to simulate loaded state + vm.domains.push({ name: "example.com", allowAllUsers: true, allowedEmails: [] }); + const initialDomainsCount = vm.domains.length; + + // Try to add a domain that already exists + vm.newDomain = "example.com"; + await vm.addDomain(); + + expect(vm.domains.length).toBe(initialDomainsCount); + }); + + it("should clear input after adding domain", async () => { + const vm = wrapper.vm; + + vm.newDomain = "newdomain.com"; + await vm.addDomain(); + + expect(vm.newDomain).toBe(""); + }); + }); + + describe("Removing Domains", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + it("should show confirmation dialog when removing domain", async () => { + const vm = wrapper.vm; + + // Add a test domain first + vm.domains.push({ name: "test.com", allowAllUsers: true, allowedEmails: [] }); + await nextTick(); + + // Mock q.dialog instead of Dialog.create + const mockDialog = vi.fn().mockReturnValue({ + onOk: vi.fn((callback) => callback()), + onCancel: vi.fn(), + }); + + // Replace the dialog method on the component's q object + vm.$q.dialog = mockDialog; + + await vm.removeDomain(0); + + expect(mockDialog).toHaveBeenCalled(); + expect(vm.domains).toHaveLength(0); + }); + }); + + describe("Email Management", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + it("should add email to domain with specific users", async () => { + const vm = wrapper.vm; + + // Find a domain that doesn't allow all users + const restrictedDomain = vm.domains.find(d => !d.allowAllUsers); + if (restrictedDomain) { + const initialEmailCount = restrictedDomain.allowedEmails.length; + + restrictedDomain.newEmail = `newuser@${restrictedDomain.name}`; + await vm.addEmail(restrictedDomain); + + expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount + 1); + expect(restrictedDomain.allowedEmails).toContain(`newuser@${restrictedDomain.name}`); + expect(restrictedDomain.newEmail).toBe(""); + } + }); + + it("should not add invalid email", async () => { + const vm = wrapper.vm; + + const restrictedDomain = vm.domains.find(d => !d.allowAllUsers); + if (restrictedDomain) { + const initialEmailCount = restrictedDomain.allowedEmails.length; + + restrictedDomain.newEmail = "invalid-email"; + await vm.addEmail(restrictedDomain); + + expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount); + } + }); + + it("should not add duplicate email", async () => { + const vm = wrapper.vm; + + const restrictedDomain = vm.domains.find(d => !d.allowAllUsers); + if (restrictedDomain && restrictedDomain.allowedEmails.length > 0) { + const initialEmailCount = restrictedDomain.allowedEmails.length; + const existingEmail = restrictedDomain.allowedEmails[0]; + + restrictedDomain.newEmail = existingEmail; + await vm.addEmail(restrictedDomain); + + expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount); + } + }); + }); + + describe("Saving Changes", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + it("should save changes successfully", async () => { + const vm = wrapper.vm; + + // Add some test data + vm.domains.push({ + name: "testdomain.com", + allowAllUsers: false, + allowedEmails: ["user@testdomain.com"] + }); + + await vm.saveChanges(); + + expect(mockDomainManagement.updateDomainRestrictions).toHaveBeenCalledWith( + "test-meta-org", + expect.objectContaining({ + domains: expect.arrayContaining([ + expect.objectContaining({ + domain: expect.any(String), + allow_all_users: expect.any(Boolean), + allowed_emails: expect.any(Array), + }), + ]), + }) + ); + }); + + it("should validate domains before saving", async () => { + const vm = wrapper.vm; + + // Add a domain without emails when not allowing all users + vm.domains.push({ + name: "test.com", + allowAllUsers: false, + allowedEmails: [], + newEmail: "", + }); + + await vm.saveChanges(); + + // Should not call API if validation fails + expect(vm.saving).toBe(false); + }); + + it("should handle save errors gracefully", async () => { + mockDomainManagement.updateDomainRestrictions.mockRejectedValue( + new Error("API Error") + ); + + const vm = wrapper.vm; + await vm.saveChanges(); + + expect(vm.saving).toBe(false); + }); + + it("should emit saved event on successful save", async () => { + const vm = wrapper.vm; + + await vm.saveChanges(); + + expect(wrapper.emitted().saved).toBeTruthy(); + }); + }); + + describe("Form Reset", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + it("should reset form and reload data", async () => { + const vm = wrapper.vm; + + // Modify some data + vm.newDomain = "test-input"; + + // Clear the mock call count from beforeEach/mount + mockDomainManagement.getDomainRestrictions.mockClear(); + + await vm.resetForm(); + + expect(vm.newDomain).toBe(""); + expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledTimes(1); // Called once by resetForm + }); + }); + + describe("Error Handling", () => { + beforeEach(() => { + mockDomainManagement.getDomainRestrictions.mockRejectedValue( + new Error("API Error") + ); + }); + + it("should handle API errors gracefully on load", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Component should still mount and show empty state + expect(wrapper.exists()).toBeTruthy(); + }); + }); + + describe("UI State Management", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + it("should show loading state during save", async () => { + const vm = wrapper.vm; + + // Mock a slow API call + mockDomainManagement.updateDomainRestrictions.mockImplementation( + () => new Promise(resolve => setTimeout(resolve, 100)) + ); + + const savePromise = vm.saveChanges(); + + expect(vm.saving).toBe(true); + + await savePromise; + + expect(vm.saving).toBe(false); + }); + + it("should display domain count correctly", async () => { + const vm = wrapper.vm; + + // Add some test data first + vm.domains.push({ name: "test.com", allowAllUsers: true, allowedEmails: [] }); + await nextTick(); + + // Check that domains are loaded and count is correct + expect(vm.domains.length).toBeGreaterThan(0); + expect(typeof vm.domains.length).toBe('number'); + }); + + it("should show no domain message when no domains exist", async () => { + const vm = wrapper.vm; + + // Clear all domains + vm.domains.splice(0, vm.domains.length); + await nextTick(); + + const noDomainMessage = wrapper.find(".text-center"); + expect(noDomainMessage.exists()).toBeTruthy(); + }); + }); + + describe("Component Cleanup", () => { + it("should emit cancel event", async () => { + wrapper = createWrapper(); + const vm = wrapper.vm; + + // Clear the mock call count from mount + mockDomainManagement.getDomainRestrictions.mockClear(); + + // Call resetForm method directly instead of finding the button + await vm.resetForm(); + + // resetForm should be called which reloads data + expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledTimes(1); + }); + }); + + describe("Advanced Negative Testing", () => { + beforeEach(async () => { + wrapper = createWrapper(); + await flushPromises(); + }); + + describe("Security and XSS Prevention", () => { + it("should sanitize malicious input in domain names", async () => { + const vm = wrapper.vm; + + const xssAttempts = [ + "", + "javascript:void(0)", + "onload=alert(1)", + "", + "';DROP TABLE domains;--" + ]; + + for (const xss of xssAttempts) { + vm.newDomain = xss; + expect(vm.isValidDomain(xss)).toBe(false); + } + }); + + it("should prevent SQL injection attempts", async () => { + const vm = wrapper.vm; + + const sqlInjections = [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + "admin'/*", + "1' UNION SELECT * FROM domains--", + "'; INSERT INTO domains VALUES('evil.com'); --" + ]; + + for (const sql of sqlInjections) { + expect(vm.isValidDomain(sql)).toBe(false); + expect(vm.isValidEmail(sql + "@test.com", "test.com")).toBe(false); + } + }); + }); + + describe("Data Type and Format Validation", () => { + it("should handle null and undefined values gracefully", async () => { + const vm = wrapper.vm; + + // Test null/undefined domain validation + expect(vm.isValidDomain(null)).toBe(true); // null treated as empty + expect(vm.isValidDomain(undefined)).toBe(true); // undefined treated as empty + + // Test null/undefined email validation + expect(vm.isValidEmail(null, "test.com")).toBe(false); + expect(vm.isValidEmail(undefined, "test.com")).toBe(false); + expect(vm.isValidEmail("test@test.com", null)).toBe(false); + expect(vm.isValidEmail("test@test.com", undefined)).toBe(false); + }); + + it("should reject non-string inputs", async () => { + const vm = wrapper.vm; + + const nonStringInputs = [ + 123, + true, + {}, + [], + function() {}, + Symbol('test') + ]; + + for (const input of nonStringInputs) { + expect(vm.isValidDomain(input)).toBe(false); + expect(vm.isValidEmail(input, "test.com")).toBe(false); + } + }); + + it("should handle extremely large input values", async () => { + const vm = wrapper.vm; + + const hugeDomain = "a".repeat(10000) + ".com"; + const hugeEmail = "a".repeat(10000) + "@test.com"; + + expect(vm.isValidDomain(hugeDomain)).toBe(false); + expect(vm.isValidEmail(hugeEmail, "test.com")).toBe(false); + }); + }); + + describe("Error Boundary Testing", () => { + it("should handle component errors gracefully", async () => { + const vm = wrapper.vm; + + // Force an error condition + mockDomainManagement.getDomainRestrictions.mockRejectedValue( + new Error("Critical system failure") + ); + + // Component should still render + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.exists()).toBeTruthy(); + expect(wrapper.vm.domains.length).toBe(0); + }); + + it("should recover from temporary API failures", async () => { + const vm = wrapper.vm; + + // First call fails + mockDomainManagement.updateDomainRestrictions + .mockRejectedValueOnce(new Error("Network timeout")) + .mockResolvedValueOnce({ success: true }); + + // First save should fail gracefully + await vm.saveChanges(); + expect(vm.saving).toBe(false); + + // Second save should succeed + await vm.saveChanges(); + expect(vm.saving).toBe(false); + }); + }); + + describe("Performance and Load Testing", () => { + it("should handle rapid user interactions", async () => { + const vm = wrapper.vm; + + // Rapid form submissions + for (let i = 0; i < 10; i++) { + vm.newDomain = `rapid${i}.com`; + vm.addDomain(); // Don't await to simulate rapid clicking + } + + // Wait for all operations to complete + await flushPromises(); + + expect(wrapper.exists()).toBeTruthy(); + }); + + it("should maintain performance with large datasets", async () => { + const vm = wrapper.vm; + + // Add many domains + const largeDomainSet = []; + for (let i = 0; i < 200; i++) { + largeDomainSet.push({ + name: `domain${i}.com`, + allowAllUsers: i % 2 === 0, + allowedEmails: i % 2 === 0 ? [] : [`user${i}@domain${i}.com`] + }); + } + + vm.domains.splice(0, vm.domains.length, ...largeDomainSet); + + // Should still function normally + vm.newDomain = "newdomain.com"; + await vm.addDomain(); + + expect(vm.domains.length).toBe(201); + }); + }); + + describe("State Consistency Testing", () => { + it("should maintain data integrity during concurrent operations", async () => { + const vm = wrapper.vm; + + // Set up initial state + vm.domains.push({ + name: "test.com", + allowAllUsers: false, + allowedEmails: ["user1@test.com"], + newEmail: "" + }); + + const domain = vm.domains[vm.domains.length - 1]; + + // Concurrent operations on the same domain + const operations = [ + vm.addEmail(domain), + vm.saveChanges(), + vm.resetForm() + ]; + + await Promise.all(operations); + + // State should remain consistent + expect(wrapper.exists()).toBeTruthy(); + }); + + it("should prevent race conditions in form validation", async () => { + const vm = wrapper.vm; + + // Rapid validation calls + const validationPromises = [ + Promise.resolve(vm.isValidDomain("test1.com")), + Promise.resolve(vm.isValidDomain("test2.com")), + Promise.resolve(vm.isValidEmail("user@test.com", "test.com")), + Promise.resolve(vm.isValidEmail("admin@test.com", "test.com")) + ]; + + const results = await Promise.all(validationPromises); + + // All validations should complete successfully + expect(results.every(result => typeof result === 'boolean')).toBe(true); + }); + }); + + describe("Resource Cleanup Testing", () => { + it("should clean up properly when component is destroyed during operations", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const vm = wrapper.vm; + + // Start long-running operations + vm.newDomain = "test.com"; + const addPromise = vm.addDomain(); + const savePromise = vm.saveChanges(); + + // Destroy component immediately + wrapper.unmount(); + wrapper = null; + + // Operations should complete without errors + await expect(Promise.allSettled([addPromise, savePromise])).resolves.not.toThrow(); + }); + + it("should handle memory pressure scenarios", async () => { + const vm = wrapper.vm; + + // Create a realistic memory pressure scenario by adding many domains directly to the component + const initialCount = vm.domains.length; + + // Add many domains to the component's internal state + for (let i = 0; i < 100; i++) { + vm.domains.push({ + name: `stress-test-${i}.com`, + allowAllUsers: false, + allowedEmails: [`user@stress-test-${i}.com`], + newEmail: "" + }); + } + + // Component operations should still work under load + vm.newDomain = "memory-test.com"; + await vm.addDomain(); + + expect(vm.domains.some(d => d.name === "memory-test.com")).toBe(true); + expect(vm.domains.length).toBe(initialCount + 100 + 1); // Original + stress domains + new domain + + // Clean up - remove the stress test domains + vm.domains.splice(initialCount, vm.domains.length - initialCount); + }); + }); + }); +}); diff --git a/web/src/components/settings/DomainManagement.test-helpers.ts b/web/src/components/settings/DomainManagement.test-helpers.ts new file mode 100644 index 0000000000..fad8a7593d --- /dev/null +++ b/web/src/components/settings/DomainManagement.test-helpers.ts @@ -0,0 +1,202 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { vi } from "vitest"; + +/** + * Test helpers for DomainManagement component testing + */ + +export const mockDomainData = { + simple: { + domains: [ + { + domain: "example.com", + allow_all_users: true, + allowed_emails: [], + }, + ], + }, + complex: { + domains: [ + { + domain: "company.com", + allow_all_users: false, + allowed_emails: ["admin@company.com", "user1@company.com"], + }, + { + domain: "partner.org", + allow_all_users: true, + allowed_emails: [], + }, + { + domain: "client.net", + allow_all_users: false, + allowed_emails: ["contact@client.net"], + }, + ], + }, + empty: { + domains: [], + }, +}; + +export const createMockStore = (overrides = {}) => ({ + state: { + zoConfig: { + meta_org: "test-meta-org", + }, + selectedOrganization: { + identifier: "test-meta-org", + }, + ...overrides, + }, +}); + +export const createMockRouter = () => ({ + replace: vi.fn(), + push: vi.fn(), + go: vi.fn(), +}); + +export const waitForDOM = async (wrapper: any, selector: string, timeout = 1000) => { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + const checkElement = () => { + const element = wrapper.find(selector); + if (element.exists()) { + resolve(element); + } else if (Date.now() - startTime > timeout) { + reject(new Error(`Element ${selector} not found within ${timeout}ms`)); + } else { + setTimeout(checkElement, 10); + } + }; + checkElement(); + }); +}; + +export const createDomainManagementWrapper = (mount: any, component: any, options = {}) => { + const defaultOptions = { + attachTo: "#app", + shallow: false, + global: { + plugins: [], + stubs: {}, + }, + ...options, + }; + + return mount(component, defaultOptions); +}; + +export const mockNotifySuccess = () => ({ + type: "positive", + timeout: 3000, +}); + +export const mockNotifyError = () => ({ + type: "negative", + timeout: 3000, +}); + +export const domainValidationTestCases = [ + // Valid cases + { input: "example.com", expected: true, description: "basic domain" }, + { input: "sub.example.com", expected: true, description: "subdomain" }, + { input: "my-company.org", expected: true, description: "domain with hyphen" }, + { input: "test123.co.uk", expected: true, description: "domain with numbers and country code" }, + + // Invalid cases + { input: "", expected: true, description: "empty string (considered valid for optional)" }, + { input: "invalid", expected: false, description: "single word" }, + { input: "spaces in domain.com", expected: false, description: "domain with spaces" }, + { input: ".example.com", expected: false, description: "starting with dot" }, + { input: "example..com", expected: false, description: "double dots" }, + { input: "example.com.", expected: true, description: "trailing dot (actually valid in DNS)" }, + { input: "-example.com", expected: false, description: "starting with hyphen" }, + { input: "example-.com", expected: false, description: "ending with hyphen before dot (invalid per RFC)" }, +]; + +export const emailValidationTestCases = [ + // Valid cases + { + email: "user@example.com", + domain: "example.com", + expected: true, + description: "valid email for domain" + }, + { + email: "admin@company.org", + domain: "company.org", + expected: true, + description: "valid email with different TLD" + }, + { + email: "test.user+tag@example.com", + domain: "example.com", + expected: true, + description: "email with plus sign and dots" + }, + + // Invalid cases + { + email: "", + domain: "example.com", + expected: false, + description: "empty email" + }, + { + email: "invalid-email", + domain: "example.com", + expected: false, + description: "invalid email format" + }, + { + email: "user@different.com", + domain: "example.com", + expected: false, + description: "email from different domain" + }, + { + email: "user@subdomain.example.com", + domain: "example.com", + expected: false, + description: "email from subdomain when parent domain expected" + }, +]; + +export const apiErrorScenarios = [ + { + name: "Network Error", + error: new Error("Network request failed"), + expectedMessage: "Network request failed", + }, + { + name: "API Error", + error: { response: { data: { message: "API Error" } } }, + expectedMessage: "API Error", + }, + { + name: "Unauthorized", + error: { response: { status: 401, statusText: "Unauthorized" } }, + expectedMessage: "Unauthorized", + }, + { + name: "Server Error", + error: { response: { status: 500, statusText: "Internal Server Error" } }, + expectedMessage: "Internal Server Error", + }, +]; diff --git a/web/src/components/settings/DomainManagement.vue b/web/src/components/settings/DomainManagement.vue new file mode 100644 index 0000000000..0a7c587336 --- /dev/null +++ b/web/src/components/settings/DomainManagement.vue @@ -0,0 +1,534 @@ + + + + + + + + \ No newline at end of file diff --git a/web/src/components/settings/index.vue b/web/src/components/settings/index.vue index 949a4279f5..bf30c3f7c8 100644 --- a/web/src/components/settings/index.vue +++ b/web/src/components/settings/index.vue @@ -130,6 +130,20 @@ along with this program. If not, see . :label="t('settings.nodes')" content-class="tab_content" /> + { routeGuard(to, from, next); }, }, + { + path: "domain_management", + name: "domainManagement", + component: () => import("@/components/settings/DomainManagement.vue"), + meta: { + keepAlive: true, + }, + beforeEnter(to: any, from: any, next: any) { + routeGuard(to, from, next); + }, + }, ], ); } diff --git a/web/src/locales/languages/en.json b/web/src/locales/languages/en.json index 208d8683c5..673712673e 100644 --- a/web/src/locales/languages/en.json +++ b/web/src/locales/languages/en.json @@ -1142,7 +1142,34 @@ "subscription_status": "Subscription Status", "created_on": "Created on", "trial_expiry": "Trial Expiry", - "actions": "Actions" + "actions": "Actions", + "ssoDomainRestrictions": "SSO Domain Restrictions", + "ssoDomainRestrictionsDescription": "Control which domains and specific users are allowed to login using SSO providers", + "domainAndAllowedUsers": "Domain and allowed users", + "domainPlaceholder": "e.g. example.com", + "domainHint": "Enter domain names without the {at_sign} symbol", + "addDomain": "Add Domain", + "domainConfiguredCount": "{count} domain(s) configured", + "allowAllUsersFromDomain": "Allow all users from {domain}", + "allowOnlySpecificUsers": "Allow only specific users from {domain}", + "allUsersAllowedMessage": "All users with email addresses ending in {domain} will be allowed to login.", + "emailPlaceholder": "e.g. user{domain}", + "addEmail": "Add Email", + "saveChanges": "Save Changes", + "invalidDomain": "Please enter a valid domain name", + "invalidEmail": "Please enter a valid email address for this domain", + "domainAlreadyExists": "This domain has already been added", + "domainAdded": "Domain added successfully", + "confirmRemoveDomain": "Are you sure you want to remove the domain '{domain}'?", + "domainRemoved": "Domain removed successfully", + "emailAlreadyExists": "This email address has already been added", + "emailAdded": "Email address added successfully", + "confirmRemoveEmail": "Are you sure you want to remove the email '{email}'?", + "emailRemoved": "Email address removed successfully", + "domainNeedsEmails": "Domain '{domain}' must have at least one email address when not allowing all users", + "domainSettingsSaved": "Domain settings saved successfully", + "errorSavingDomainSettings": "Error saving domain settings", + "noDomainMessage": "No domains configured. Add domains above to restrict access." }, "reports": { "header": "Reports", diff --git a/web/src/services/domainManagement.spec.ts b/web/src/services/domainManagement.spec.ts new file mode 100644 index 0000000000..4ce66d91a9 --- /dev/null +++ b/web/src/services/domainManagement.spec.ts @@ -0,0 +1,259 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; +import domainManagement from "./domainManagement"; +import http from "./http"; + +// Mock the http service with a scoped namespace +vi.mock("./http", () => ({ + default: vi.fn(() => ({ + get: vi.fn(), + put: vi.fn(), + })), +})); + +// Mock Vuex store with scoped namespace +vi.mock("vuex", () => ({ + useStore: vi.fn(() => ({ + state: { + selectedOrganization: { + identifier: "test-org", + }, + }, + })), +})); + +describe("domainManagement Service", () => { + let mockHttpInstance: any; + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + vi.resetAllMocks(); + + // Create a mock HTTP instance + mockHttpInstance = { + get: vi.fn(), + put: vi.fn(), + }; + + // Make http() return our mock instance + (http as any).mockReturnValue(mockHttpInstance); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + afterAll(() => { + // Final cleanup + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + describe("getDomainRestrictions", () => { + it("should call GET endpoint with correct metaOrg parameter", async () => { + const metaOrg = "test-meta-org"; + const mockResponse = { + data: { + domains: [ + { + domain: "example.com", + allow_all_users: true, + allowed_emails: [], + }, + ], + }, + }; + + mockHttpInstance.get.mockResolvedValue(mockResponse); + + const result = await domainManagement.getDomainRestrictions(metaOrg); + + expect(mockHttpInstance.get).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management` + ); + expect(result).toEqual(mockResponse); + }); + + it("should handle API errors gracefully", async () => { + const metaOrg = "test-meta-org"; + const errorMessage = "Network error"; + + mockHttpInstance.get.mockRejectedValue(new Error(errorMessage)); + + await expect( + domainManagement.getDomainRestrictions(metaOrg) + ).rejects.toThrow(errorMessage); + + expect(mockHttpInstance.get).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management` + ); + }); + + it("should handle empty response", async () => { + const metaOrg = "test-meta-org"; + const mockResponse = { data: null }; + + mockHttpInstance.get.mockResolvedValue(mockResponse); + + const result = await domainManagement.getDomainRestrictions(metaOrg); + + expect(mockHttpInstance.get).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management` + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe("updateDomainRestrictions", () => { + it("should call PUT endpoint with correct parameters", async () => { + const metaOrg = "test-meta-org"; + const domainData = { + domains: [ + { + domain: "example.com", + allow_all_users: false, + allowed_emails: ["user@example.com"], + }, + ], + }; + const mockResponse = { success: true }; + + mockHttpInstance.put.mockResolvedValue(mockResponse); + + const result = await domainManagement.updateDomainRestrictions( + metaOrg, + domainData as any + ); + + expect(mockHttpInstance.put).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management`, + domainData + ); + expect(result).toEqual(mockResponse); + }); + + it("should handle update errors", async () => { + const metaOrg = "test-meta-org"; + const domainData = { + domains: [ + { + domain: "example.com", + allow_all_users: true, + allowed_emails: [], + }, + ], + }; + const errorMessage = "Update failed"; + + mockHttpInstance.put.mockRejectedValue(new Error(errorMessage)); + + await expect( + domainManagement.updateDomainRestrictions(metaOrg, domainData as any) + ).rejects.toThrow(errorMessage); + + expect(mockHttpInstance.put).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management`, + domainData + ); + }); + + it("should handle complex domain configurations", async () => { + const metaOrg = "test-meta-org"; + const complexDomainData = { + domains: [ + { + domain: "company.com", + allow_all_users: false, + allowed_emails: [ + "admin@company.com", + "user1@company.com", + "user2@company.com", + ], + }, + { + domain: "partner.org", + allow_all_users: true, + allowed_emails: [], + }, + ], + }; + const mockResponse = { success: true, updated: 2 }; + + mockHttpInstance.put.mockResolvedValue(mockResponse); + + const result = await domainManagement.updateDomainRestrictions( + metaOrg, + complexDomainData as any + ); + + expect(mockHttpInstance.put).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management`, + complexDomainData + ); + expect(result).toEqual(mockResponse); + }); + + it("should handle empty domain data", async () => { + const metaOrg = "test-meta-org"; + const emptyDomainData = { domains: [] }; + const mockResponse = { success: true }; + + mockHttpInstance.put.mockResolvedValue(mockResponse); + + const result = await domainManagement.updateDomainRestrictions( + metaOrg, + emptyDomainData as any + ); + + expect(mockHttpInstance.put).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management`, + emptyDomainData + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe("Edge Cases", () => { + it("should handle special characters in metaOrg", async () => { + const metaOrg = "test-org-with-special-chars_123"; + const mockResponse = { data: { domains: [] } }; + + mockHttpInstance.get.mockResolvedValue(mockResponse); + + await domainManagement.getDomainRestrictions(metaOrg); + + expect(mockHttpInstance.get).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management` + ); + }); + + it("should handle undefined metaOrg parameter", async () => { + const metaOrg = undefined as any; + + mockHttpInstance.get.mockResolvedValue({ data: null }); + + await domainManagement.getDomainRestrictions(metaOrg); + + expect(mockHttpInstance.get).toHaveBeenCalledWith( + `/api/${metaOrg}/domain_management` + ); + }); + }); +}); diff --git a/web/src/services/domainManagement.ts b/web/src/services/domainManagement.ts new file mode 100644 index 0000000000..8c1d1ed91a --- /dev/null +++ b/web/src/services/domainManagement.ts @@ -0,0 +1,43 @@ +// Copyright 2025 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import http from "./http"; +import { useStore } from "vuex"; + +interface DomainRestriction { + name: string; + allowAllUsers: boolean; + allowedEmails: string[]; +} + +interface DomainSettings { + domains: DomainRestriction[]; +} + +const domainManagement = { + // Get SSO domain restrictions for an organization + getDomainRestrictions: (metaOrg: string) => { + const store = useStore(); + return http().get(`/api/${metaOrg}/domain_management`); + }, + // Update specific domain settings + updateDomainRestrictions: (metaOrg: string, domain: DomainRestriction) => { + const store = useStore(); + return http().put(`/api/${metaOrg}/domain_management`, domain); + }, +}; + +export default domainManagement;