mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
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>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-9e3829325dfd706f5f6d715837cabd691add3c6e4d6e25644926530139c82ff9">+114/-0</a>
</td>
</tr>
<tr>
<td><strong>mod.rs</strong><dd><code>Integrate domain check in auth
redirect</code>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-ca09c9c9b020049bdbc3382c466bfcca80daac92e98760d9a9a76172abf34049">+18/-10</a>
</td>
</tr>
<tr>
<td><strong>mod.rs</strong><dd><code>Initialize domain management cache
watcher</code>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-f2e4c29b8eef22189ec704a832633c4bd135a24444f054864c98064bd0963681">+15/-9</a>
</td>
</tr>
<tr>
<td><strong>DomainManagement.vue</strong><dd><code>Implement domain
restrictions UI component</code>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-5e47cd01fcb63f7ef27dc64d2ecf66c39e17097e29944951c3ae1bfe715bb97c">+469/-0</a>
</td>
</tr>
<tr>
<td><strong>index.vue</strong><dd><code>Add domain management tab in
settings</code>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-699c63979f851c48ad440a078e93282fd797a18fc065e5331b2e41122ddb8b69">+14/-0</a>
</td>
</tr>
<tr>
<td><strong>domainManagement.ts</strong><dd><code>Add domain management
HTTP service</code>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-2edddfd1d2d86e7facfd2c863a126897f1f7bdf1c02f5fa58c6f9b55f23ad700">+43/-0</a>
</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>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-a1cfd40da1ab6cd4db0181fd8664965034298047b3b7c3320c5385897a7c4aed">+1/-0</a>
</td>
</tr>
<tr>
<td><strong>mod.rs</strong><dd><code>Register domain management
routes</code>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-02556e3f515441b5d2b6bbea7829df488beac03c4cc41309a6638ae3c0e0aec6">+4/-1</a>
</td>
</tr>
<tr>
<td><strong>useManagementRoutes.ts</strong><dd><code>Add
domain_management route entry</code>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-b71a1d39b1fab0749f0668d5f16382536fbda6e2f0c92c56a972fb5d8b5835f5">+11/-0</a>
</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>
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/7563/files#diff-892aa3d75ab6e129ef9116aa21bec1b9a15ba69ce5366d2a19128078c690d824">+28/-1</a>
</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:
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user