1
0
forked from baron/baron-sso

org chart 연동기능 추가

This commit is contained in:
2026-04-29 21:00:51 +09:00
parent 438f844f2b
commit 01e7b15c46
25 changed files with 5141 additions and 2940 deletions

View File

@@ -114,24 +114,100 @@ test.describe("Tenants Management", () => {
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
});
test("should clarify organization import creates org tenants and users together", async ({
test("should export and import tenant CSV without organization/user combined import", async ({
page,
}) => {
let exportRequested = false;
let importRequested = false;
let importBody = "";
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/export")) {
exportRequested = true;
return route.fulfill({
body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n",
contentType: "text/csv",
headers: {
...headers,
"Content-Disposition": 'attachment; filename="tenants.csv"',
},
});
}
if (url.includes("/import")) {
importRequested = true;
importBody = route.request().postData() ?? "";
return route.fulfill({
json: { created: 0, updated: 1, failed: 0, errors: [] },
headers,
});
}
if (method === "GET") {
return route.fulfill({
json: {
items: [
{
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.continue();
});
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
{ timeout: 20000 },
);
await page
.locator("button")
.filter({ hasText: /임포트|Import/i })
.first()
.click();
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
await expect(page.getByTestId("tenant-template-btn")).toBeVisible();
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
await expect(page.getByRole("dialog")).toContainText(
/조직\/사용자 통합 일괄 등록|organization and user batch registration/i,
const download = page.waitForEvent("download");
await page.getByTestId("tenant-export-btn").click();
await download;
expect(exportRequested).toBe(true);
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
mimeType: "text/csv",
buffer: Buffer.from(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Tenant Alpha,COMPANY,,tenant-alpha-copy,Imported memo,imported.example.com\n",
),
});
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await expect(page.getByTestId("tenant-import-candidate")).toContainText(
"Tenant Alpha",
);
await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText(
/갱신 1|Updated 1/i,
);
expect(importRequested).toBe(true);
expect(importBody).toContain("tenant-alpha-id");
});
test("should show validation error on empty name", async ({ page }) => {

View File

@@ -0,0 +1,119 @@
import { expect, test } from "@playwright/test";
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
const oidcAuthority = "https://sso.hmac.kr/oidc";
const clientId = "adminfront";
test.describe("Tenants CSV live E2E", () => {
test.skip(!liveE2E, "Set LIVE_BACKEND_E2E=1 to run against a live backend.");
test.beforeEach(async ({ page, baseURL }) => {
await page.addInitScript(
({ authority, client_id }) => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
window.localStorage.setItem("X-Mock-Role", "super_admin");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "live-e2e-placeholder-token",
token_type: "Bearer",
profile: {
sub: "live-e2e-admin",
name: "Live E2E Admin",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
);
},
{ authority: oidcAuthority, client_id: clientId },
);
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const headers = { ...route.request().headers() };
delete headers.authorization;
headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({
json: {
issuer: oidcAuthority,
authorization_endpoint: `${oidcAuthority}/auth`,
token_endpoint: `${oidcAuthority}/token`,
jwks_uri: `${oidcAuthority}/jwks`,
userinfo_endpoint: `${oidcAuthority}/userinfo`,
end_session_endpoint: `${oidcAuthority}/session/end`,
},
});
});
});
test("exports and imports tenant CSV through the admin UI", async ({
page,
baseURL,
}) => {
const slug = `csv-live-${Date.now()}`;
let tenantId = "";
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
);
await expect(page.getByTestId("tenant-export-btn")).toBeVisible();
await expect(page.getByTestId("tenant-import-btn")).toBeVisible();
const download = page.waitForEvent("download");
await page.getByTestId("tenant-export-btn").click();
const exported = await download;
expect(exported.suggestedFilename()).toContain("tenants");
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
mimeType: "text/csv",
buffer: Buffer.from(
`tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Live CSV Tenant,COMPANY,,${slug},Live E2E import,${slug}.example.com\n`,
),
});
await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인");
await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText(
/생성 1|Created 1/i,
);
const listResponse = await page.request.get(
`${baseURL}/api/v1/admin/tenants?limit=1000&offset=0`,
{ headers: { "X-Test-Role": "super_admin" } },
);
expect(listResponse.ok()).toBeTruthy();
const list = await listResponse.json();
const imported = list.items.find(
(tenant: { slug: string }) => tenant.slug === slug,
);
expect(imported).toMatchObject({
name: "Live CSV Tenant",
slug,
description: "Live E2E import",
domains: [`${slug}.example.com`],
});
tenantId = imported.id;
const deleteResponse = await page.request.delete(
`${baseURL}/api/v1/admin/tenants/${tenantId}`,
{ headers: { "X-Test-Role": "super_admin" } },
);
expect(deleteResponse.status()).toBe(204);
});
});

View File

@@ -79,14 +79,73 @@ test.describe("User Management", () => {
],
},
},
{
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
slug: "tech-planning",
name: "기술기획",
type: "USER_GROUP",
parentId: "hanmac-company-id",
config: {},
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
config: {},
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
config: {},
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
config: {},
},
{
id: "system-id",
slug: "system",
name: "System Tenant",
type: "SYSTEM",
config: {},
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
type: "COMPANY",
config: {},
},
],
total: 1,
total: 7,
limit: 100,
offset: 0,
},
});
}
if (url.match(/\/admin\/tenants$/) && method === "POST") {
const postData = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-tenant-id",
slug: postData?.slug || "personal",
name: postData?.name || "Personal",
type: postData?.type || "PERSONAL",
config: {},
},
});
}
if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
return route.fulfill({
json: {
@@ -107,6 +166,21 @@ test.describe("User Management", () => {
});
}
if (
url.match(/\/admin\/tenants\/03dbe16b-e47b-4f72-927b-782807d67a35$/) &&
method === "GET"
) {
return route.fulfill({
json: {
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
slug: "tech-planning",
name: "기술기획",
type: "USER_GROUP",
config: {},
},
});
}
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
return route.fulfill({
json: {
@@ -185,6 +259,7 @@ test.describe("User Management", () => {
name: "John Doe Updated",
email: "john@test.com",
loginId: "johndoe_updated",
status: "inactive",
},
});
}
@@ -197,9 +272,11 @@ test.describe("User Management", () => {
id: "u-1",
name: "John Doe",
email: "john@test.com",
phone: "010-1111-2222",
loginId: "johndoe",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
@@ -285,8 +362,26 @@ test.describe("User Management", () => {
// Ensure the page title is loaded
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
const userTypeTabs = page.getByRole("tab");
await expect(userTypeTabs).toHaveText([
"한맥가족 구성원",
"외부 기업 회원",
"개인 회원",
]);
await expect(page.getByRole("tab", { name: /외부 기업 회원/i })).toBeVisible();
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toBeVisible();
await expect(page.getByRole("tab", { name: /개인 회원/i })).toBeVisible();
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
// Select Tenant first (important for schema fields to show up)
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
await page.selectOption("select#tenantSlug", "test-tenant");
// Fill required fields
@@ -305,6 +400,303 @@ test.describe("User Management", () => {
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
});
test("should export users through the authenticated API client", async ({
page,
}) => {
let authorizationHeader: string | undefined;
await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => {
authorizationHeader = route.request().headers().authorization;
return route.fulfill({
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="users.csv"',
},
body: "email,name\njohn@test.com,John Doe\n",
});
});
await page.goto("/users");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: /내보내기|Export/i }).click(),
]);
expect(download.suggestedFilename()).toBe("users.csv");
expect(authorizationHeader).toBe("Bearer fake-token");
});
test("should show contact info in one row, hide roles, and toggle user status", async ({
page,
}) => {
let updatePayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "PUT") {
updatePayload = route.request().postDataJSON();
return route.fulfill({
json: {
id: "u-1",
name: "John Doe",
email: "john@test.com",
phone: "010-1111-2222",
loginId: "johndoe",
status: "inactive",
createdAt: "2026-04-01T00:00:00Z",
},
});
}
return route.fallback();
});
await page.goto("/users");
const table = page.locator("table");
await expect(
table.getByRole("columnheader", { name: /ROLE|역할/i }),
).toHaveCount(0);
await expect(page.getByTestId("user-contact-u-1")).toContainText(
"John Doe john@test.com 010-1111-2222",
);
await page.getByTestId("user-status-toggle-u-1").click();
await expect
.poll(() => updatePayload)
.toMatchObject({ status: "inactive" });
});
test("should create a Hanmac family user with tenant appointments and no representative affiliation", async ({
page,
}) => {
let createPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "new-user-id",
name: "Family User",
email: "family@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
await expect(page.locator("select#role")).toHaveCount(0);
await expect(page.locator("input#department")).toHaveCount(0);
await expect(page.getByText(/대표 소속/i)).toHaveCount(0);
await page.getByRole("button", { name: /^추가$/i }).click();
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
await expect(
page.getByTestId("appointment-tenant-owner-line-0"),
).toBeVisible();
await expect(page.getByTestId("appointment-position-line-0")).toBeVisible();
await page.getByRole("button", { name: /테넌트 선택/i }).click();
await expect(page.getByTitle(/테넌트 선택/i)).toHaveAttribute(
"src",
/\/login\?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id$/,
);
await page.evaluate(() => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
},
}),
);
});
await expect(page.getByText("기술기획")).toBeVisible();
await page.getByLabel(/조직장/i).check();
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
await page.getByLabel(/^직급$/i).fill("책임");
await page.locator('input[name="name"]').fill("Family User");
await page.locator('input[name="email"]').fill("family@test.com");
await page.getByRole("button", { name: /생성/i }).click();
await expect
.poll(() => createPayload)
.toMatchObject({
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
jobTitle: "플랫폼 운영",
position: "책임",
},
],
},
});
expect(createPayload).not.toHaveProperty("role");
expect(createPayload).not.toHaveProperty("department");
expect(createPayload).not.toHaveProperty("tenantSlug");
expect(createPayload).not.toHaveProperty("companyCode");
expect(createPayload).not.toHaveProperty("primaryTenantId");
});
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
page,
}) => {
await page.goto("/users/new");
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
const tenantOptionValues = await page
.locator("select#tenantSlug option")
.evaluateAll((options) =>
options.map((option) => (option as HTMLOptionElement).value),
);
expect(tenantOptionValues).toContain("test-tenant");
expect(tenantOptionValues).toContain("external-tenant");
expect(tenantOptionValues).not.toContain("");
expect(tenantOptionValues).not.toContain("system");
expect(tenantOptionValues).not.toContain("hanmac-family");
expect(tenantOptionValues).not.toContain("hanmac-company");
expect(tenantOptionValues).not.toContain("hanmac-team");
expect(tenantOptionValues).not.toContain("tech-planning");
});
test("should create a personal user and provision Personal tenant when missing", async ({
page,
}) => {
let tenantPayload: Record<string, unknown> | undefined;
let createPayload: Record<string, unknown> | undefined;
await page.route(/\/admin\/tenants$/, async (route) => {
if (route.request().method() === "POST") {
tenantPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-tenant-id",
slug: "personal",
name: "Personal",
type: "PERSONAL",
config: {},
},
});
}
return route.fallback();
});
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createPayload = route.request().postDataJSON();
return route.fulfill({
status: 201,
json: {
id: "personal-user-id",
name: "Personal User",
email: "personal@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await page.getByRole("tab", { name: /개인 회원/i }).click();
await expect(page.getByTestId("personal-tenant-summary")).toContainText(
/Personal/i,
);
await page.locator('input[name="name"]').fill("Personal User");
await page.locator('input[name="email"]').fill("personal@test.com");
await page.getByRole("button", { name: /생성/i }).click();
await expect
.poll(() => tenantPayload)
.toMatchObject({ name: "Personal", slug: "personal", type: "PERSONAL" });
await expect
.poll(() => createPayload)
.toMatchObject({
tenantSlug: "personal",
metadata: { userType: "personal", hanmacFamily: false },
});
});
test("should show Hanmac family appointments layout on user detail", async ({
page,
}) => {
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: {
id: "u-1",
name: "Family User",
email: "family@test.com",
phone: "010-1111-2222",
loginId: "familyuser",
role: "user",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
isOwner: true,
jobTitle: "플랫폼 운영",
position: "책임",
},
],
},
},
});
}
return route.fallback();
});
await page.goto("/users/u-1");
await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active");
await expect(
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
await expect(
page.getByTestId("detail-appointment-tenant-owner-line-0"),
).toContainText(/기술기획|조직장/);
await expect(
page.getByTestId("detail-appointment-position-line-0"),
).toBeVisible();
});
test("should show conflict error when creating with an existing Login ID", async ({
page,
}) => {
@@ -312,6 +704,8 @@ test.describe("User Management", () => {
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
// Select Tenant first (important for schema fields to show up)
await page.selectOption("select#tenantSlug", "test-tenant");

View File

@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
const liveE2E = process.env.LIVE_BACKEND_E2E === "1";
const oidcAuthority = "https://sso.hmac.kr/oidc";
const clientId = "adminfront";
test.describe("Users CSV live E2E", () => {
test.skip(!liveE2E, "Set LIVE_BACKEND_E2E=1 to run against a live backend.");
test.beforeEach(async ({ page, baseURL }) => {
await page.addInitScript(
({ authority, client_id }) => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
window.localStorage.setItem("X-Mock-Role", "super_admin");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "live-e2e-placeholder-token",
token_type: "Bearer",
profile: {
sub: "live-e2e-admin",
name: "Live E2E Admin",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
);
},
{ authority: oidcAuthority, client_id: clientId },
);
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const headers = { ...route.request().headers() };
delete headers.authorization;
headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({
json: {
issuer: oidcAuthority,
authorization_endpoint: `${oidcAuthority}/auth`,
token_endpoint: `${oidcAuthority}/token`,
jwks_uri: `${oidcAuthority}/jwks`,
userinfo_endpoint: `${oidcAuthority}/userinfo`,
end_session_endpoint: `${oidcAuthority}/session/end`,
},
});
});
});
test("exports user CSV through the authenticated admin UI path", async ({
page,
}) => {
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(/사용자|Users/i);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: /내보내기|Export/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain("users");
const path = await download.path();
expect(path).toBeTruthy();
const csv = fs.readFileSync(path as string, "utf8");
expect(csv).toContain("ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt");
expect(csv).not.toContain("Role");
expect(csv).not.toContain("Department");
});
});