feat: Domain management APIs (#7563)

### **PR Type**
Enhancement


___

### **Description**
- Add domain management backend APIs and handlers

- Integrate enterprise domain check in auth redirect

- Extend job init to watch/cache domain settings

- Implement DomainManagement.vue UI and integration


___

### **Changes diagram**

```mermaid
flowchart LR
  UI["DomainManagement.vue UI"]
  TS["domainManagement service (TS)"]
  API["HTTP API (/api/{org}/domain_management)"]
  Handler["Rust handler (domain_management/mod.rs)"]
  BE["domain_management service (Rust)"]
  Auth["Auth redirect domain check"]
  
  UI -- "GET/PUT calls" --> TS
  TS -- "HTTP request" --> API
  API -- "routes to" --> Handler
  Handler -- "calls service" --> BE
  Auth -- "calls is_email_allowed" --> BE
```


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>6
files</summary><table>
<tr>
<td><strong>mod.rs</strong><dd><code>Add domain management API
handlers</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-9e3829325dfd706f5f6d715837cabd691add3c6e4d6e25644926530139c82ff9">+114/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>mod.rs</strong><dd><code>Integrate domain check in auth
redirect</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-ca09c9c9b020049bdbc3382c466bfcca80daac92e98760d9a9a76172abf34049">+18/-10</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>mod.rs</strong><dd><code>Initialize domain management cache
watcher</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-f2e4c29b8eef22189ec704a832633c4bd135a24444f054864c98064bd0963681">+15/-9</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>DomainManagement.vue</strong><dd><code>Implement domain
restrictions UI component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-5e47cd01fcb63f7ef27dc64d2ecf66c39e17097e29944951c3ae1bfe715bb97c">+469/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>index.vue</strong><dd><code>Add domain management tab in
settings</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-699c63979f851c48ad440a078e93282fd797a18fc065e5331b2e41122ddb8b69">+14/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>domainManagement.ts</strong><dd><code>Add domain management
HTTP service</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-2edddfd1d2d86e7facfd2c863a126897f1f7bdf1c02f5fa58c6f9b55f23ad700">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Configuration
changes</strong></td><td><details><summary>3 files</summary><table>
<tr>
<td><strong>mod.rs</strong><dd><code>Register domain_management
module</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-a1cfd40da1ab6cd4db0181fd8664965034298047b3b7c3320c5385897a7c4aed">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>mod.rs</strong><dd><code>Register domain management
routes</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-02556e3f515441b5d2b6bbea7829df488beac03c4cc41309a6638ae3c0e0aec6">+4/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>useManagementRoutes.ts</strong><dd><code>Add
domain_management route entry</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-b71a1d39b1fab0749f0668d5f16382536fbda6e2f0c92c56a972fb5d8b5835f5">+11/-0</a>&nbsp;
&nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1
files</summary><table>
<tr>
<td><strong>en.json</strong><dd><code>Add domain management
translations</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-892aa3d75ab6e129ef9116aa21bec1b9a15ba69ce5366d2a19128078c690d824">+28/-1</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

___

> <details> <summary> Need help?</summary><li>Type <code>/help how to
...</code> in the comments thread for any questions about PR-Agent
usage.</li><li>Check out the <a
href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a>
for more information.</li></details>

---------

Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
This commit is contained in:
udev
2025-07-17 10:26:28 +05:30
committed by GitHub
co-authored by Bhargav
parent f5b8ec4fa2
commit fa630f12c8
16 changed files with 2777 additions and 21 deletions
@@ -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 <http://www.gnu.org/licenses/>.
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<String>) -> Result<HttpResponse, Error> {
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<String>,
body: web::Json<DomainManagementRequest>,
) -> Result<HttpResponse, Error> {
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)),
}
}
}
}
+2
View File
@@ -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;
+20 -10
View File
@@ -559,17 +559,27 @@ pub async fn redirect(req: HttpRequest) -> Result<HttpResponse, Error> {
.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();
+3 -1
View File
@@ -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
+17 -9
View File
@@ -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?;
@@ -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 <http://www.gnu.org/licenses/>.
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(())
}
+2
View File
@@ -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,
@@ -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 <http://www.gnu.org/licenses/>.
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 = [
"<script>alert('xss')</script>.com",
"javascript:alert(1).com",
"../../etc/passwd.com",
"null.com\0",
"test.com<img src=x onerror=alert(1)>",
];
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 = [
"<script>alert('xss')</script>@example.com",
"javascript:alert(1)@example.com",
"test@<script>alert(1)</script>.com",
"user@example.com<img src=x onerror=alert(1)>",
"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
});
});
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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 = [
"<script>alert('xss')</script>",
"javascript:void(0)",
"onload=alert(1)",
"<img src=x onerror=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);
});
});
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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",
},
];
@@ -0,0 +1,534 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="q-px-md q-py-md domain_management">
<div class="text-h6 text-bold q-mb-xs">
{{ t("settings.ssoDomainRestrictions") }}
</div>
<div class="text-body2 text-grey-7 q-mb-lg">
{{ t("settings.ssoDomainRestrictionsDescription") }}
</div>
<!-- Domain Input Section -->
<div class="q-mb-xs">
<div class="text-body1 text-bold q-mb-md">
{{ t("settings.domainAndAllowedUsers") }}
</div>
<div class="row q-gutter-md items-center q-mb-md">
<div class="col-auto">
<q-input
v-model="newDomain"
:label="t('settings.domainPlaceholder')"
:hint="t('settings.domainHint', { 'at_sign': '@' })"
color="input-border"
bg-color="input-bg"
class="domain-input"
outlined
dense
@keydown.enter="addDomain"
:rules="[
(val) => isValidDomain(val) || t('settings.invalidDomain')
]"
/>
</div>
<div class="col-auto q-my-none">
<q-btn
:label="t('settings.addDomain')"
color="primary"
class="text-bold text-capitalize no-border"
@click="addDomain"
:disabled="!newDomain || !isValidDomain(newDomain)"
unelevated
/>
</div>
</div>
<div class="text-caption text-grey-6 q-mb-md" v-if="domains.length > 0">
{{ t("settings.domainConfiguredCount", { count: domains.length }) }}
</div>
</div>
<!-- Domain List -->
<div v-if="domains.length > 0" class="q-mb-lg">
<template v-for="(domain, index) in domains" :key="domain?.name || `domain-${index}`">
<div
v-if="domain && domain.name"
class="domain-card q-mb-xs"
>
<div class="domain-header row items-center justify-between q-px-md q-py-sm">
<div class="text-body1 text-bold">{{ domain.name }}</div>
<q-btn
icon="close"
flat
round
dense
color="negative"
@click="removeDomain(index)"
:title="t('common.delete')"
/>
</div>
<div class="q-pa-md">
<!-- Radio Button Options -->
<div class="q-mb-xs">
<q-radio
v-model="domain.allowAllUsers"
:val="true"
:label="t('settings.allowAllUsersFromDomain', { domain: '@'+domain.name })"
color="primary"
/>
</div>
<div class="q-mb-md">
<q-radio
v-model="domain.allowAllUsers"
:val="false"
:label="t('settings.allowOnlySpecificUsers', { domain: '@'+domain.name })"
color="primary"
/>
</div>
<!-- Info message for all users -->
<div
v-if="domain.allowAllUsers"
class="q-pa-sm bg-blue-1 text-blue-8 rounded-borders q-mb-md"
>
{{ t("settings.allUsersAllowedMessage", { domain: '@'+domain.name }) }}
</div>
<!-- Specific users section -->
<div v-if="!domain.allowAllUsers" class="specific-users-section">
<div class="row q-gutter-md items-center q-mb-md">
<div class="col">
<q-input
v-model="domain.newEmail"
:label="t('settings.emailPlaceholder', { domain: '@' + domain.name })"
color="input-border"
bg-color="input-bg"
class="email-input"
outlined
dense
@keydown.enter="addEmail(domain)"
:rules="[
(val) => !val || isValidEmail(val, domain.name) || t('settings.invalidEmail')
]"
/>
</div>
<div class="col-auto q-my-none">
<q-btn
:label="t('settings.addEmail')"
color="secondary"
class="text-bold text-capitalize no-border"
@click="addEmail(domain)"
:disabled="!domain.newEmail || !isValidEmail(domain.newEmail, domain.name)"
unelevated
dense
/>
</div>
</div>
<!-- Email List -->
<div v-if="domain.allowedEmails && domain.allowedEmails.length > 0">
<div
v-for="(email, emailIndex) in domain.allowedEmails"
:key="email"
class="email-item row items-center justify-between q-pa-sm q-mb-xs"
>
<div class="text-body2">{{ email }}</div>
<q-btn
icon="close"
flat
round
dense
size="sm"
color="negative"
@click="removeEmail(domain, emailIndex)"
:title="t('common.delete')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<div v-else class="text-h6 text-grey-6 q-mt-md q-mb-lg tw-w-full text-center q-pa-lg domain-card">
{{ t("settings.noDomainMessage") }}
</div>
<!-- Action Buttons -->
<div class="row q-gutter-md">
<q-btn
:label="t('common.cancel')"
class="text-bold text-capitalize no-border"
flat
color="grey-7"
@click="resetForm"
/>
<q-btn
:label="t('settings.saveChanges')"
color="secondary"
class="text-bold text-capitalize no-border"
unelevated
@click="saveChanges"
:loading="saving"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onActivated } from "vue";
import { useI18n } from "vue-i18n";
import { useQuasar } from "quasar";
import { useStore } from "vuex";
import domainManagement from "@/services/domainManagement";
import { useRouter } from "vue-router";
import { add } from "date-fns";
interface Domain {
name: string;
allowAllUsers: boolean;
allowedEmails: string[];
newEmail: string;
}
const { t } = useI18n();
const q = useQuasar();
const store = useStore();
const router = useRouter();
const newDomain = ref("");
const domains = reactive<Domain[]>([]);
const saving = ref(false);
const emit = defineEmits(["cancel", "saved"]);
onMounted(() => {
if(store.state.zoConfig.meta_org == store.state.selectedOrganization.identifier) {
loadDomainSettings();
} else {
router.replace({
name: "general",
query: {
org_identifier: store.state.selectedOrganization.identifier,
},
})
}
});
onActivated(() => {
if(store.state.zoConfig.meta_org == store.state.selectedOrganization.identifier) {
loadDomainSettings();
} else {
router.replace({
name: "general",
query: {
org_identifier: store.state.selectedOrganization.identifier,
},
})
}
});
const loadDomainSettings = async () => {
try {
const response = await domainManagement.getDomainRestrictions(store.state.zoConfig.meta_org);
if (response.data && response.data.domains) {
const loadedDomains = response.data.domains
.filter((domain: any) => domain && typeof domain === 'object' && domain.domain) // Filter out invalid entries
.map((domain: any) => ({
name: domain.domain,
allowAllUsers: domain.allow_all_users,
allowedEmails: domain.allowed_emails || []
}));
domains.splice(0, domains.length, ...loadedDomains);
}
} catch (error: any) {
// If the API doesn't exist yet or returns an error, use example data
console.warn("Domain restrictions API not available, using example data:", error);
const existingDomains = [];
domains.splice(0, domains.length, ...existingDomains);
}
};
const isValidDomain = (domain: any): boolean => {
// Handle null, undefined, and non-string inputs
if (domain === null || domain === undefined) return true; // Empty is valid
if (typeof domain !== 'string') return false; // Non-strings are invalid
// Handle empty strings - empty is valid, but whitespace-only is not
const trimmed = domain.trim();
if (!trimmed) return domain.length === 0; // Empty string is valid, whitespace-only is not
// Security: Check for potentially malicious content (more targeted patterns)
const maliciousPatterns = [
'<script', '</script', 'javascript:', 'DROP TABLE', 'SELECT FROM', 'INSERT INTO',
'UPDATE SET', 'DELETE FROM', 'UNION SELECT', '--', '/*', '*/', '\0', '\n', '\r'
];
const upperDomain = trimmed.toUpperCase();
if (maliciousPatterns.some(pattern => upperDomain.includes(pattern.toUpperCase()))) {
return false;
}
// Length validation (DNS limit is 253 characters)
if (trimmed.length > 253) return false;
// Remove trailing dot if present (valid in DNS)
const cleanDomain = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
// Improved domain validation that properly handles hyphens and edge cases
// Domain parts can contain letters, numbers, and hyphens (but not start/end with hyphens)
// Each label can be 1-63 characters, and the domain must have at least one dot
const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
try {
return domainRegex.test(cleanDomain);
} catch (error) {
return false; // Any regex error means invalid
}
};
const isValidEmail = (email: any, domain: any): boolean => {
// Handle null, undefined, and non-string inputs
if (email === null || email === undefined || typeof email !== 'string') return false;
if (domain === null || domain === undefined || typeof domain !== 'string') return false;
// Handle empty strings
if (!email.trim() || !domain.trim()) return false;
// Security: Check for potentially malicious content
const maliciousPatterns = [
'<', '>', 'script', 'javascript:', 'DROP', 'SELECT', 'INSERT', 'UPDATE', 'DELETE',
'UNION', 'CREATE', 'ALTER', 'TABLE', 'FROM', '--', '/*', '*/', "'", '"',
'\0', '\n', '\r', '\t'
];
const upperEmail = email.toUpperCase();
if (maliciousPatterns.some(pattern => upperEmail.includes(pattern.toUpperCase()))) {
return false;
}
// Length validation (practical email limit)
if (email.length > 254 || domain.length > 253) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
try {
if (!emailRegex.test(email)) return false;
// Check if email belongs to the domain
return email.toLowerCase().endsWith(`@${domain.toLowerCase()}`);
} catch (error) {
return false; // Any error means invalid
}
};
const addDomain = () => {
if (!newDomain.value || !isValidDomain(newDomain.value)) return;
// Check if domain already exists
if (domains.some(d => d.name.toLowerCase() === newDomain.value.toLowerCase())) {
q.notify({
type: "negative",
message: t("settings.domainAlreadyExists"),
timeout: 3000,
});
return;
}
domains.push({
name: newDomain.value,
allowAllUsers: true,
allowedEmails: [],
newEmail: ""
});
newDomain.value = "";
// q.notify({
// type: "positive",
// message: t("settings.domainAdded"),
// timeout: 3000,
// });
};
const removeDomain = (index: number) => {
q.dialog({
title: t("common.confirm"),
message: t("settings.confirmRemoveDomain", { domain: domains[index].name }),
cancel: true,
persistent: true,
}).onOk(() => {
domains.splice(index, 1);
q.notify({
type: "positive",
message: t("settings.domainRemoved"),
timeout: 3000,
});
});
};
const addEmail = (domain: Domain) => {
if (!domain.newEmail || !isValidEmail(domain.newEmail, domain.name)) return;
// Check if email already exists
if (domain.allowedEmails.includes(domain.newEmail.toLowerCase())) {
q.notify({
type: "negative",
message: t("settings.emailAlreadyExists"),
timeout: 3000,
});
return;
}
domain.allowedEmails.push(domain.newEmail.toLowerCase());
domain.newEmail = "";
q.notify({
type: "positive",
message: t("settings.emailAdded"),
timeout: 3000,
});
};
const removeEmail = (domain: Domain, emailIndex: number) => {
q.dialog({
title: t("common.confirm"),
message: t("settings.confirmRemoveEmail", { email: domain.allowedEmails[emailIndex] }),
cancel: true,
persistent: true,
}).onOk(() => {
domain.allowedEmails.splice(emailIndex, 1);
q.notify({
type: "positive",
message: t("settings.emailRemoved"),
timeout: 3000,
});
});
};
const saveChanges = async () => {
saving.value = true;
try {
// Validate all domains have proper configuration
for (const domain of domains) {
if (!domain.allowAllUsers && domain.allowedEmails.length === 0) {
q.notify({
type: "negative",
message: t("settings.domainNeedsEmails", { domain: domain.name }),
timeout: 3000,
});
saving.value = false;
return;
}
}
// Prepare data for API
const domainData: any = {
domains: domains.map(domain => ({
domain: domain.name,
allow_all_users: domain.allowAllUsers,
allowed_emails: !domain.allowAllUsers ? domain.allowedEmails : []
}))
};
// Save to backend API
await domainManagement.updateDomainRestrictions(store.state.zoConfig.meta_org, domainData);
q.notify({
type: "positive",
message: t("settings.domainSettingsSaved"),
timeout: 3000,
});
emit("saved", domains);
} catch (error: any) {
q.notify({
type: "negative",
message: error?.message || t("settings.errorSavingDomainSettings"),
timeout: 3000,
});
} finally {
saving.value = false;
}
};
const resetForm = () => {
newDomain.value = "";
loadDomainSettings();
};
</script>
<style scoped lang="scss">
.domain-input {
width: 300px;
}
.email-input {
min-width: 250px;
}
.domain-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
background: white;
}
.domain-header {
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
border-radius: 8px 8px 0 0;
}
.specific-users-section {
margin-left: 24px;
}
.email-item {
background: #f9f9f9;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.body--dark {
.domain-card {
border-color: #444;
background: #1e1e1e;
}
.domain-header {
background: #2a2a2a;
border-bottom-color: #444;
}
.email-item {
background: #2a2a2a;
border-color: #444;
}
}
</style>
<style lang="scss">
.domain_management .q-field__bottom {
padding-left: 0px;
}
</style>
+14
View File
@@ -130,6 +130,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="t('settings.nodes')"
content-class="tab_content"
/>
<q-route-tab
v-if="config.isEnterprise == 'true' && isMetaOrg"
data-test="domain-management-tab"
name="domain_management"
:to="{
name: 'domainManagement',
query: {
org_identifier: store.state.selectedOrganization.identifier,
},
}"
icon="domain"
:label="t('settings.ssoDomainRestrictions')"
content-class="tab_content"
/>
<q-route-tab
v-if="config.isCloud == 'true' && isMetaOrg"
data-test="organization-management-tab"
@@ -99,6 +99,17 @@ const useManagementRoutes = () => {
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);
},
},
],
);
}
+28 -1
View File
@@ -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",
+259
View File
@@ -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 <http://www.gnu.org/licenses/>.
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`
);
});
});
});
+43
View File
@@ -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 <http://www.gnu.org/licenses/>.
/* 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;