1
0
forked from baron/baron-sso

orgfront 버그 픽스

This commit is contained in:
2026-06-10 09:36:57 +09:00
parent 28478309fa
commit c880b3c333
33 changed files with 853 additions and 130 deletions

View File

@@ -143,6 +143,7 @@ vi.mock("../../lib/adminApi", () => ({
login_count: 3,
},
]),
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
fetchPasswordPolicy: vi.fn(async () => ({
minLength: 12,
lowercase: true,
@@ -196,6 +197,7 @@ vi.mock("../../lib/adminApi", () => ({
worksmobileId: "works-user-1",
worksmobileName: "Engineer User",
worksmobileEmail: "engineer@example.com",
worksmobileDomainId: 1001,
worksmobilePrimaryOrgId: "works-org-1",
worksmobilePrimaryOrgName: "기술연구팀",
status: "matched",
@@ -380,17 +382,19 @@ describe("adminfront large page coverage smoke", () => {
fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
);
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
"tenant-company",
"user-2",
expect.any(String),
undefined,
"InitialPassword!1",
),
);
const credentialBatchId = vi.mocked(
adminApi.enqueueWorksmobileUserSync,
).mock.calls[0][2];
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
});
@@ -416,6 +420,10 @@ describe("adminfront large page coverage smoke", () => {
fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
);
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
@@ -424,21 +432,20 @@ describe("adminfront large page coverage smoke", () => {
1,
"tenant-company",
"user-2",
expect.any(String),
undefined,
"InitialPassword!1",
);
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
2,
"tenant-company",
"user-3",
expect.any(String),
undefined,
"InitialPassword!1",
);
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
});
it("downloads or deletes Worksmobile credential batches from history", async () => {
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
vi.spyOn(window, "confirm").mockReturnValue(true);
it("renders and retries Worksmobile jobs from history", async () => {
renderWithProviders(
<Routes>
<Route
@@ -450,45 +457,20 @@ describe("adminfront large page coverage smoke", () => {
);
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
await screen.findByText("credential-batch-1");
expect(
screen.getByRole("button", {
name: "credential-batch-pending 비밀번호 CSV 다운로드",
}),
).toBeDisabled();
fireEvent.click(
screen.getByRole("button", {
name: "credential-batch-1 비밀번호 CSV 다운로드",
}),
);
await waitFor(() =>
expect(
adminApi.downloadWorksmobileInitialPasswordsCSV,
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
);
expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
expect(screen.getByText("failed")).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", {
name: "credential-batch-1 비밀번호 값 삭제",
}),
);
fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
await waitFor(() =>
expect(
adminApi.deleteWorksmobileCredentialBatchPasswords,
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
"tenant-company",
"job-1",
),
);
fireEvent.click(
screen.getByRole("button", {
name: "credential-batch-1 실패 사유 보기",
}),
);
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
});
it("enqueues Worksmobile password reset as a credential batch", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
it("opens Worksmobile password management for matched users", async () => {
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
renderWithProviders(
<Routes>
<Route
@@ -504,17 +486,21 @@ describe("adminfront large page coverage smoke", () => {
await screen.findAllByText("Engineer User");
fireEvent.click(
screen.getByRole("button", {
name: "Engineer User 비밀번호 재설정",
name: "Engineer User 비밀번호 관리",
}),
);
await waitFor(() =>
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
"tenant-company",
"user-1",
expect.any(String),
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining(
"https://auth.worksmobile.com/integrate/password/manage",
),
"_blank",
"noopener,noreferrer",
);
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
const [url] = openSpy.mock.calls[0] ?? [];
const parsed = new URL(String(url));
expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin");
expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
});
});

View File

@@ -149,9 +149,13 @@ describe("DataIntegrityPage", () => {
it("renders Ory SSOT cache management inside data integrity", async () => {
renderPage();
fireEvent.click(await screen.findByRole("tab", { name: "Ory SSOT 시스템" }));
fireEvent.click(
await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
);
expect((await screen.findAllByText("Ory SSOT 시스템")).length).toBeGreaterThan(0);
expect(
(await screen.findAllByText("Ory SSOT 시스템")).length,
).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("152")).toBeInTheDocument();

View File

@@ -84,7 +84,9 @@ describe("UserProjectionPage", () => {
await screen.findByText("Ory SSOT 시스템");
expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
expect(screen.queryByRole("button", { name: /초기화 후 재구축/ })).toBeNull();
expect(
screen.queryByRole("button", { name: /초기화 후 재구축/ }),
).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {

View File

@@ -209,6 +209,7 @@ export default function GlobalCustomClaimsPage() {
>
<Input
value={claim.key}
name={`global-claim-definition-key-${claim.id}`}
className="font-mono text-xs"
placeholder="claim_key"
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
@@ -218,6 +219,7 @@ export default function GlobalCustomClaimsPage() {
/>
<Input
value={claim.label}
name={`global-claim-definition-label-${claim.id}`}
placeholder={t(
"ui.admin.users.global_custom_claims.label_placeholder",
"표시 이름",
@@ -233,6 +235,7 @@ export default function GlobalCustomClaimsPage() {
"Claim 타입",
)}
value={claim.valueType}
name={`global-claim-definition-value-type-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
onChange={(event) =>
updateClaim(claim.id, {
@@ -253,6 +256,7 @@ export default function GlobalCustomClaimsPage() {
"읽기 권한",
)}
value={claim.readPermission}
name={`global-claim-definition-read-permission-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`}
onChange={(event) =>
@@ -274,6 +278,7 @@ export default function GlobalCustomClaimsPage() {
"쓰기 권한",
)}
value={claim.writePermission}
name={`global-claim-definition-write-permission-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`}
onChange={(event) =>
@@ -291,6 +296,7 @@ export default function GlobalCustomClaimsPage() {
</select>
<Input
value={claim.description || ""}
name={`global-claim-definition-description-${claim.id}`}
placeholder={t(
"ui.admin.users.global_custom_claims.description_placeholder",
"설명",

View File

@@ -186,7 +186,9 @@ describe("UserDetailPage Worksmobile employee number", () => {
expect(valueInput).toHaveAttribute("type", "date");
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
fireEvent.click(screen.getByRole("button", { name: /사용자 Claim 값 저장/ }));
fireEvent.click(
screen.getByRole("button", { name: /사용자 Claim 값 저장/ }),
);
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
expect(updateUserMock).toHaveBeenCalledWith(

View File

@@ -86,9 +86,7 @@ describe("tenantTree utility", () => {
expect(currentBase?.recursiveMemberCount).toBe(17);
expect(currentBase?.children[0]?.recursiveMemberCount).toBe(7);
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(
2,
);
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(2);
});
it("keeps total member counts when descendants are not loaded on the current page", () => {

View File

@@ -26,8 +26,11 @@ describe("adminfront form field diagnostics", () => {
for (const file of sourceFiles("src")) {
const source = readFileSync(file, "utf8");
let match: RegExpExecArray | null;
while ((match = formFieldTagPattern.exec(source))) {
for (
let match = formFieldTagPattern.exec(source);
match !== null;
match = formFieldTagPattern.exec(source)
) {
const tag = match[0];
if (/\b(id|name)\s*=/.test(tag)) continue;
if (/\{\.\.\s*[^}]+\}/.test(tag)) continue;

View File

@@ -1,10 +1,11 @@
import { expect } from "vitest";
export function anonymousFormFields(container: ParentNode) {
return Array.from(container.querySelectorAll("input, select, textarea")).filter(
return Array.from(
container.querySelectorAll("input, select, textarea"),
).filter(
(field) =>
!field.getAttribute("id")?.trim() &&
!field.getAttribute("name")?.trim(),
!field.getAttribute("id")?.trim() && !field.getAttribute("name")?.trim(),
);
}

View File

@@ -67,12 +67,14 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"ui.admin.ory_ssot.title": "Ory SSOT 시스템",
"msg.admin.ory_ssot.flush_confirm":
"Redis identity cache 키만 비우시겠습니까?",
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush에 실패했습니다.",
"msg.admin.ory_ssot.flush_error":
"Redis identity cache flush에 실패했습니다.",
"msg.admin.ory_ssot.flush_success":
"Redis identity cache key {{count}}개를 비웠습니다.",
"msg.admin.ory_ssot.forbidden.description":
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
"msg.admin.ory_ssot.load_error": "Ory SSOT 시스템 상태를 불러오지 못했습니다.",
"msg.admin.ory_ssot.load_error":
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
"msg.admin.ory_ssot.subtitle":
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
@@ -156,8 +158,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"ui.admin.ory_ssot.summary.status": "Status",
"ui.admin.ory_ssot.summary.updated_at": "Updated at",
"ui.admin.ory_ssot.title": "Ory SSOT System",
"msg.admin.ory_ssot.flush_confirm":
"Flush only Redis identity cache keys?",
"msg.admin.ory_ssot.flush_confirm": "Flush only Redis identity cache keys?",
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush failed.",
"msg.admin.ory_ssot.flush_success":
"Flushed {{count}} Redis identity cache keys.",

View File

@@ -179,9 +179,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
await expect(page.locator('a[href="/tenants"]')).toBeVisible();
await expect(page.locator('a[href="/api-keys"]')).toBeVisible();
await expect(page.locator('a[href="/audit-logs"]')).toBeVisible();
await expect(
page.locator('a[href="/system/ory-ssot"]'),
).toBeVisible();
await expect(page.locator('a[href="/system/ory-ssot"]')).toBeVisible();
await expect(
page.locator('a[href="/system/data-integrity"]'),
).toBeVisible();

View File

@@ -202,6 +202,7 @@ test.describe("Tenants Management", () => {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="tenant-users.csv"',
"access-control-expose-headers": "content-disposition",
},
body: "email,name\nmember@example.com,Member User\n",
});

View File

@@ -39,6 +39,7 @@ export default defineConfig({
"../common/**/node_modules/**",
"../common/.pnpm-store/**",
`${commonRoot}/theme/**`,
`${commonRoot}/core/components/audit/AuditLogTable.tsx`,
`${commonRoot}/core/pagination/*.worker.ts`,
`${commonRoot}/core/query/queryClient.ts`,
],

View File

@@ -100,7 +100,6 @@ func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T)
output := &strings.Builder{}
count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client)
if err != nil {
t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err)
}

View File

@@ -39,7 +39,7 @@ type DevHandler struct {
KetoOutbox repository.KetoOutboxRepository
RPSvc service.RelyingPartyService
TenantSvc service.TenantService
DeveloperSvc *service.DeveloperService
DeveloperSvc developerRequestService
RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageQueries domain.RPUsageQueryRepository
Auth interface {
@@ -47,6 +47,16 @@ type DevHandler struct {
}
}
type developerRequestService interface {
RequestAccess(ctx context.Context, req domain.DeveloperRequest) error
GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error)
GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error)
ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error)
ApproveRequest(ctx context.Context, id uint, adminNotes string) error
RejectRequest(ctx context.Context, id uint, adminNotes string) error
CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error
}
func NewDevHandler(
redis domain.RedisRepository,
secretRepo domain.ClientSecretRepository,
@@ -426,7 +436,28 @@ func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domai
return false
}
allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions")
return err == nil && allowed
if err == nil && allowed {
return true
}
return h.hasApprovedDeveloperRequest(c, profile, tenantID)
}
func (h *DevHandler) hasApprovedDeveloperRequest(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
if h.DeveloperSvc == nil || profile == nil {
return false
}
userID := strings.TrimSpace(profile.ID)
tenantID = strings.TrimSpace(tenantID)
if userID == "" || tenantID == "" {
return false
}
status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID)
if err != nil || status == nil {
return false
}
return status.Status == domain.DeveloperRequestStatusApproved &&
strings.TrimSpace(status.UserID) == userID &&
strings.TrimSpace(status.TenantID) == tenantID
}
func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool {

View File

@@ -62,6 +62,54 @@ func (m *devMockKetoService) ListObjects(ctx context.Context, ns, rel, sub strin
return args.Get(0).([]string), args.Error(1)
}
type devMockDeveloperService struct {
mock.Mock
}
func (m *devMockDeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error {
args := m.Called(ctx, req)
return args.Error(0)
}
func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) {
args := m.Called(ctx, userID, tenantID)
if req, ok := args.Get(0).(*domain.DeveloperRequest); ok {
return req, args.Error(1)
}
return nil, args.Error(1)
}
func (m *devMockDeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) {
args := m.Called(ctx, id)
if req, ok := args.Get(0).(*domain.DeveloperRequest); ok {
return req, args.Error(1)
}
return nil, args.Error(1)
}
func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) {
args := m.Called(ctx, userID, status)
if requests, ok := args.Get(0).([]domain.DeveloperRequest); ok {
return requests, args.Error(1)
}
return nil, args.Error(1)
}
func (m *devMockDeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
args := m.Called(ctx, id, adminNotes)
return args.Error(0)
}
func (m *devMockDeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
args := m.Called(ctx, id, adminNotes)
return args.Error(0)
}
func (m *devMockDeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
args := m.Called(ctx, id, adminNotes)
return args.Error(0)
}
type devMockRedisRepo struct {
data map[string]string
}
@@ -1521,6 +1569,66 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
mockKeto.AssertExpectations(t)
}
func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisible(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
body["client_secret"] = "generated-secret"
return httpJSONAny(r, http.StatusCreated, body), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(false, nil).Maybe()
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "System", "global", "manage_all").Return(false, nil).Maybe()
developerSvc := new(devMockDeveloperService)
developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperRequest{
UserID: "user-1",
TenantID: "tenant-a",
Status: domain.DeveloperRequestStatusApproved,
}, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
Redis: &devMockRedisRepo{data: make(map[string]string)},
Keto: mockKeto,
DeveloperSvc: developerSvc,
}
app := fiber.New()
tenantID := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{
"id": "client-1",
"name": "App One",
"type": "private",
"redirectUris": []string{"http://localhost/cb"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockKeto.AssertExpectations(t)
developerSvc.AssertExpectations(t)
}
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()

View File

@@ -14,6 +14,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"maps"
"os"
"reflect"
@@ -22,6 +23,7 @@ import (
"strings"
"time"
"github.com/go-redis/redis/v8"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
@@ -315,26 +317,14 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
findRoot := func(id string) string {
curr := id
for {
p, exists := parentMap[curr]
if !exists || p == "" {
break
}
curr = p
}
return curr
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
roots[findRoot(id)] = true
roots[findTenantRootID(parentMap, id)] = true
}
// Filter tenants that belong to the same tree family
for _, t := range allTenants {
if roots[findRoot(t.ID)] {
if roots[findTenantRootID(parentMap, t.ID)] {
tenants = append(tenants, t)
}
}
@@ -2774,6 +2764,14 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
ttl := orgChartSnapshotCacheTTL()
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
)
if cacheMode == "redis" && h.OrgChartCache != nil {
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
@@ -2785,13 +2783,43 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
TTLSeconds: int(ttl.Seconds()),
}
c.Set("X-Orgfront-Cache", "HIT")
slog.Info("orgchart snapshot cache hit",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"tenant_count", len(cached.Tenants),
"user_count", len(cached.Users),
)
return c.JSON(cached)
}
slog.Warn("orgchart snapshot cache payload ignored",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
} else if err != nil && err != redis.Nil {
slog.Warn("orgchart snapshot cache read failed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
}
}
snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile)
if err != nil {
slog.Error("orgchart snapshot build failed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
snapshot.Cache = orgChartSnapshotCacheInfo{
@@ -2802,13 +2830,31 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
if cacheMode == "redis" && h.OrgChartCache != nil {
if raw, err := json.Marshal(snapshot); err == nil {
_ = h.OrgChartCache.Set(cacheKey, string(raw), ttl)
if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil {
slog.Warn("orgchart snapshot cache write failed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
}
}
c.Set("X-Orgfront-Cache", "MISS")
} else {
c.Set("X-Orgfront-Cache", "BYPASS")
}
slog.Info("orgchart snapshot request completed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
"cache_result", c.GetRespHeader("X-Orgfront-Cache"),
"tenant_count", len(snapshot.Tenants),
"user_count", len(snapshot.Users),
)
return c.JSON(snapshot)
}
@@ -2880,27 +2926,16 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
parentMap[tenant.ID] = *tenant.ParentID
}
}
findRoot := func(id string) string {
curr := id
for {
parentID, exists := parentMap[curr]
if !exists || parentID == "" {
return curr
}
curr = parentID
}
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
if strings.TrimSpace(id) != "" {
roots[findRoot(id)] = true
roots[findTenantRootID(parentMap, id)] = true
}
}
tenants := make([]domain.Tenant, 0, len(allTenants))
for _, tenant := range allTenants {
if roots[findRoot(tenant.ID)] {
if roots[findTenantRootID(parentMap, tenant.ID)] {
tenants = append(tenants, tenant)
}
}
@@ -2980,6 +3015,36 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
}
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
if profile == nil {
return "anonymous", "anonymous", ""
}
tenantID := ""
if profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
return domain.NormalizeRole(profile.Role), strings.TrimSpace(profile.ID), tenantID
}
func findTenantRootID(parentMap map[string]string, tenantID string) string {
curr := strings.TrimSpace(tenantID)
if curr == "" {
return ""
}
visited := map[string]struct{}{}
for {
parentID := strings.TrimSpace(parentMap[curr])
if parentID == "" || parentID == curr {
return curr
}
if _, exists := visited[parentID]; exists {
return parentID
}
visited[curr] = struct{}{}
curr = parentID
}
}
func orgChartSnapshotCacheTTL() time.Duration {
const defaultTTL = 5 * time.Minute
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
@@ -2996,16 +3061,26 @@ func orgChartSnapshotCacheTTL() time.Duration {
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {
slog.Warn("public orgchart rejected missing token")
return errorJSON(c, fiber.StatusUnauthorized, "share token is required")
}
link, err := h.SharedLink.ValidateToken(c.Context(), token)
if err != nil {
slog.Warn("public orgchart token validation failed",
"token_length", len(token),
"error", err,
)
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
}
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil {
slog.Error("public orgchart tenant list failed",
"link_id", link.ID,
"tenant_id", link.TenantID,
"error", err,
)
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
@@ -3016,24 +3091,12 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
}
}
findRoot := func(id string) string {
curr := id
for {
p, exists := parentMap[curr]
if !exists || p == "" {
break
}
curr = p
}
return curr
}
sharedRootID := findRoot(link.TenantID)
sharedRootID := findTenantRootID(parentMap, link.TenantID)
var filteredTenants []domain.Tenant
var tenantIDs []string
for _, t := range allTenants {
if findRoot(t.ID) == sharedRootID {
if findTenantRootID(parentMap, t.ID) == sharedRootID {
filteredTenants = append(filteredTenants, t)
}
}
@@ -3076,6 +3139,13 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
tenantSummaries = append(tenantSummaries, mapTenantSummary(t))
}
slog.Info("public orgchart request completed",
"link_id", link.ID,
"tenant_id", link.TenantID,
"shared_root_id", sharedRootID,
"tenant_count", len(tenantSummaries),
"user_count", len(publicUsers),
)
return c.JSON(fiber.Map{
"tenants": tenantSummaries,
"users": publicUsers,

View File

@@ -405,6 +405,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
familyID := "hanmac-family-id"
samanID := "saman-id"
teamID := "saman-platform-id"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: samanID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "삼안", Slug: "saman", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: teamID, Type: domain.TenantTypeUserGroup, ParentID: parent(samanID), Name: "플랫폼팀", Slug: "saman-platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
users := []domain.User{
{ID: "user-1", Email: "user@samaneng.com", Name: "Saman User", Role: domain.RoleUser, Status: domain.UserStatusActive, TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &samanID,
JoinedTenants: []domain.Tenant{
tenants[1],
},
})
return c.Next()
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 1, samanID: 1, teamID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
resp, err := app.Test(req, 1000)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Tenants, 3)
require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform"))
require.Len(t, body.Users, 1)
mockSvc.AssertExpectations(t)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -740,6 +802,25 @@ func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
return true
}
func tenantSummarySlugsMatch(got []tenantSummary, want ...string) bool {
if len(got) != len(want) {
return false
}
counts := make(map[string]int, len(want))
for _, slug := range want {
counts[slug]++
}
for _, tenant := range got {
counts[tenant.Slug]--
}
for _, count := range counts {
if count != 0 {
return false
}
}
return true
}
func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -197,3 +197,10 @@ pending = "Pending"
success = "Success"
unchanged = "Unchanged"
updated = "Updated"
[ui.common]
searching = "Searching..."
[ui.common.custom_claim_permission]
admin_only = "Admin only"
user_and_admin = "User and admin"

View File

@@ -197,3 +197,10 @@ pending = "준비 중"
success = "성공"
unchanged = "동일"
updated = "수정"
[ui.common]
searching = "검색 중..."
[ui.common.custom_claim_permission]
admin_only = "관리자만 가능"
user_and_admin = "사용자와 관리자"

View File

@@ -197,3 +197,10 @@ pending = ""
success = ""
unchanged = ""
updated = ""
[ui.common]
searching = ""
[ui.common.custom_claim_permission]
admin_only = ""
user_and_admin = ""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 810 KiB

View File

@@ -35,7 +35,8 @@ export function ClientDetailTabs({
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
{tabOrder.map((tab) => {
const isActive = tab.key === activeTab;
const labelKey = tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
const labelKey =
tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
return isActive ? (
<span
key={tab.key}

View File

@@ -38,7 +38,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
await expect(tabs).toHaveText([
"연동 설정",
"동의 및 사용자",
"사용자 Claim",
"설정",
"관계",
]);

View File

@@ -35,10 +35,22 @@ test.describe("DevFront client tenant access settings", () => {
});
test("adds and removes allowed tenants with UUID copy evidence", async ({
context,
page,
}, testInfo) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.addInitScript(() => {
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: true,
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (value: string) => {
window.localStorage.setItem("__e2e_copied_text", value);
},
},
});
});
const state = {
clients: [
@@ -82,7 +94,9 @@ test.describe("DevFront client tenant access settings", () => {
).toContainText(existingTenantId);
await page.getByTestId(`allowed-tenant-copy-${existingTenantId}`).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.poll(() =>
page.evaluate(() => window.localStorage.getItem("__e2e_copied_text")),
)
.toBe(existingTenantId);
await page

View File

@@ -2961,3 +2961,79 @@ tenant_slug = "Tenant slug"
"ui.admin.tenants.data_mgmt" = "temp"
"ui.admin.tenants.toggle_status" = "temp"
"ui.admin.users.data_mgmt" = "temp"
[msg.admin.ory_ssot]
flush_confirm = "Flush only Redis identity cache keys?"
flush_error = "Redis identity cache flush failed."
flush_success = "Flushed {{count}} Redis identity cache keys."
load_error = "Failed to load Ory SSOT system status."
subtitle = "Review Kratos source-of-truth and Redis identity cache status separately."
[msg.admin.ory_ssot.forbidden]
description = "This screen is only available to super_admin users."
[msg.admin.tenants.members]
add_error = "Failed to add members"
add_success = "Added {{count}} members."
[msg.admin.users.global_custom_claims]
description = "Manage user claim definitions shared by all RPs and default read/write permissions."
empty = "No global claims are defined."
registry = "Only defined claim keys are available for global claim value management on user details."
[ui.admin.integrity]
tab_ory_ssot = "Ory SSOT System"
[ui.admin.ory_ssot]
loading = "Loading Ory SSOT status..."
title = "Ory SSOT System"
[ui.admin.ory_ssot.actions]
flush_identity_cache = "Redis cache flush"
[ui.admin.ory_ssot.cache_card]
description = "Redis mirror/cache status for Kratos identity list and lookup operations."
title = "Redis identity cache"
[ui.admin.ory_ssot.forbidden]
title = "Access denied"
[ui.admin.ory_ssot.projection_card]
description = "PostgreSQL read model status used by admin search and statistics."
title = "Backend user read model"
[ui.admin.ory_ssot.status]
failed = "failed"
not_ready = "not ready"
ready = "ready"
[ui.admin.ory_ssot.summary]
cache_keys = "Cache keys"
last_refreshed = "Last refreshed"
last_synced = "Last read-model refresh"
local_users = "Local users"
observed_identities = "Observed identities"
status = "Status"
updated_at = "Updated at"
[ui.admin.tenants]
search_match_badge = "Search match"
[ui.admin.tenants.members]
add_existing_description = "Select search results into an add queue, then assign them in one operation."
add_queued = "Add selected members"
export = "Selected organization users CSV"
queue_empty = "Select members to add."
queue_remove = "Remove from add queue"
search_min_length = "Enter at least two characters."
search_placeholder = "Search by name or email"
[ui.admin.users.global_custom_claims]
description_placeholder = "Optional claim description"
label_placeholder = "Display name"
manage_definitions = "Manage Global Definitions"
read_permission = "Read permission"
registry = "Global Claim Registry"
title = "Global Claim Settings"
value_type = "Claim type"
write_permission = "Write permission"

View File

@@ -3387,3 +3387,79 @@ tenant_slug = "테넌트 slug"
"ui.admin.tenants.data_mgmt" = "temp"
"ui.admin.tenants.toggle_status" = "temp"
"ui.admin.users.data_mgmt" = "temp"
[msg.admin.ory_ssot]
flush_confirm = "Redis identity cache 키만 비우시겠습니까?"
flush_error = "Redis identity cache flush에 실패했습니다."
flush_success = "Redis identity cache key {{count}}개를 비웠습니다."
load_error = "Ory SSOT 시스템 상태를 불러오지 못했습니다."
subtitle = "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다."
[msg.admin.ory_ssot.forbidden]
description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
[msg.admin.tenants.members]
add_error = "구성원 추가 실패"
add_success = "{{count}}명의 구성원이 추가되었습니다."
[msg.admin.users.global_custom_claims]
description = "모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다."
empty = "정의된 전역 claim이 없습니다."
registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다."
[ui.admin.integrity]
tab_ory_ssot = "Ory SSOT 시스템"
[ui.admin.ory_ssot]
loading = "불러오는 중"
title = "Ory SSOT 시스템"
[ui.admin.ory_ssot.actions]
flush_identity_cache = "Redis cache flush"
[ui.admin.ory_ssot.cache_card]
description = "Kratos identity 목록 및 조회 작업을 위한 Redis mirror/cache 상태입니다."
title = "Redis identity cache"
[ui.admin.ory_ssot.forbidden]
title = "접근 권한이 없습니다"
[ui.admin.ory_ssot.projection_card]
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
title = "Backend 사용자 read model"
[ui.admin.ory_ssot.status]
failed = "실패"
not_ready = "준비되지 않음"
ready = "준비됨"
[ui.admin.ory_ssot.summary]
cache_keys = "Cache keys"
last_refreshed = "마지막 refresh"
last_synced = "마지막 read-model refresh"
local_users = "Local users"
observed_identities = "관측 identity"
status = "상태"
updated_at = "상태 갱신"
[ui.admin.tenants]
search_match_badge = "검색 일치"
[ui.admin.tenants.members]
add_existing_description = "검색 결과를 선택해 추가 명단에 담은 뒤 한 번에 배정합니다."
add_queued = "선택 구성원 추가"
export = "선택 조직 사용자 CSV"
queue_empty = "추가할 구성원을 선택하세요."
queue_remove = "추가 명단에서 제거"
search_min_length = "두 글자 이상 입력하세요."
search_placeholder = "이름 또는 이메일 검색"
[ui.admin.users.global_custom_claims]
description_placeholder = "설명"
label_placeholder = "표시 이름"
manage_definitions = "전역 정의 관리"
read_permission = "읽기 권한"
registry = "Global Claim Registry"
title = "전역 Claim 설정"
value_type = "Claim 타입"
write_permission = "쓰기 권한"

View File

@@ -3284,3 +3284,79 @@ tenant_slug = ""
"ui.admin.tenants.data_mgmt" = "temp"
"ui.admin.tenants.toggle_status" = "temp"
"ui.admin.users.data_mgmt" = "temp"
[msg.admin.ory_ssot]
flush_confirm = ""
flush_error = ""
flush_success = ""
load_error = ""
subtitle = ""
[msg.admin.ory_ssot.forbidden]
description = ""
[msg.admin.tenants.members]
add_error = ""
add_success = ""
[msg.admin.users.global_custom_claims]
description = ""
empty = ""
registry = ""
[ui.admin.integrity]
tab_ory_ssot = ""
[ui.admin.ory_ssot]
loading = ""
title = ""
[ui.admin.ory_ssot.actions]
flush_identity_cache = ""
[ui.admin.ory_ssot.cache_card]
description = ""
title = ""
[ui.admin.ory_ssot.forbidden]
title = ""
[ui.admin.ory_ssot.projection_card]
description = ""
title = ""
[ui.admin.ory_ssot.status]
failed = ""
not_ready = ""
ready = ""
[ui.admin.ory_ssot.summary]
cache_keys = ""
last_refreshed = ""
last_synced = ""
local_users = ""
observed_identities = ""
status = ""
updated_at = ""
[ui.admin.tenants]
search_match_badge = ""
[ui.admin.tenants.members]
add_existing_description = ""
add_queued = ""
export = ""
queue_empty = ""
queue_remove = ""
search_min_length = ""
search_placeholder = ""
[ui.admin.users.global_custom_claims]
description_placeholder = ""
label_placeholder = ""
manage_definitions = ""
read_permission = ""
registry = ""
title = ""
value_type = ""
write_permission = ""

View File

@@ -40,6 +40,15 @@ type ViewBox = {
height: number;
};
type OrgChartLoadErrorDiagnostics = {
cacheMode: "redis" | "public";
code: string;
message: string;
route: string;
status: number | null;
tenantId: string;
};
type OrgSelectionDescendantOption = {
depth: 1 | 2;
id: string;
@@ -1215,6 +1224,36 @@ function normalizeOrgSlug(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function readErrorText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function getOrgChartLoadErrorDiagnostics(
error: unknown,
options: { cacheMode: "redis" | "public"; route: string; tenantId: string },
): OrgChartLoadErrorDiagnostics {
const maybeError = error as {
config?: { url?: string };
message?: string;
response?: {
data?: { code?: unknown; error?: unknown; message?: unknown };
status?: number;
};
};
const responseData = maybeError.response?.data;
return {
cacheMode: options.cacheMode,
code: readErrorText(responseData?.code),
message:
readErrorText(responseData?.error) ||
readErrorText(responseData?.message) ||
readErrorText(maybeError.message),
route: maybeError.config?.url || options.route,
status: maybeError.response?.status ?? null,
tenantId: options.tenantId,
};
}
function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] {
const rawAppointments = user.metadata?.additionalAppointments;
if (!Array.isArray(rawAppointments)) return [];
@@ -1443,7 +1482,10 @@ export function TenantOrgChartPage() {
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(orgChartSnapshotQuery.data.tenants, visibilityMode),
filterSystemGlobalTenants(
orgChartSnapshotQuery.data.tenants,
visibilityMode,
),
).subTree;
const membershipRootNodes = buildTenantFullTree(
filterOrgChartMembershipTenants(orgChartSnapshotQuery.data.tenants),
@@ -1601,6 +1643,24 @@ export function TenantOrgChartPage() {
const isError = shareToken
? publicQuery.isError
: orgChartSnapshotQuery.isError;
const currentLoadError = shareToken
? publicQuery.error
: orgChartSnapshotQuery.error;
React.useEffect(() => {
if (!currentLoadError) return;
console.error(
"[orgfront] Org chart load failed",
getOrgChartLoadErrorDiagnostics(currentLoadError, {
cacheMode: shareToken ? "public" : "redis",
route: shareToken
? "/v1/public/orgchart"
: "/v1/admin/orgchart/snapshot",
tenantId:
tenantId ?? window.localStorage.getItem("dev_tenant_id") ?? "",
}),
);
}, [currentLoadError, shareToken, tenantId]);
const totalUsers = React.useMemo(() => {
const ids = new Set<string>();
@@ -1619,9 +1679,12 @@ export function TenantOrgChartPage() {
}
if (isError) {
const errorMessage = shareToken
? "조직도를 불러올 수 없거나 만료된 링크입니다."
: "조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.";
return (
<div className="p-8 text-center text-red-500">
.
{errorMessage}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "./adminApi";
import { buildTenantFullTree } from "./tenantTree";
function tenant(
id: string,
slug: string,
parentId?: string,
type = "USER_GROUP",
): TenantSummary {
return {
id,
type,
name: slug,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
totalMemberCount: 0,
createdAt: "2026-06-10T00:00:00.000Z",
updatedAt: "2026-06-10T00:00:00.000Z",
};
}
describe("buildTenantFullTree", () => {
it("treats a self-parent hanmac-family tenant as a root", () => {
const result = buildTenantFullTree([
tenant(
"hanmac-family-id",
"hanmac-family",
"hanmac-family-id",
"COMPANY_GROUP",
),
tenant("saman-id", "saman", "hanmac-family-id", "COMPANY"),
tenant("platform-id", "platform", "saman-id"),
]);
expect(result.subTree).toHaveLength(1);
expect(result.subTree[0]?.id).toBe("hanmac-family-id");
expect(result.subTree[0]?.children[0]?.id).toBe("saman-id");
expect(result.subTree[0]?.children[0]?.children[0]?.id).toBe(
"platform-id",
);
});
});

View File

@@ -60,9 +60,9 @@ export function buildTenantFullTree(
return total;
};
// Calculate for all top-level nodes (those without parent)
// Calculate for all top-level nodes (those without parent or with self-parent)
for (const node of tenantMap.values()) {
if (!node.parentId) {
if (!node.parentId || node.parentId === node.id) {
visitedForCalc.clear();
calculateRecursive(node);
}
@@ -81,6 +81,8 @@ export function buildTenantFullTree(
}
// If no rootId, return all top-level roots as subTree
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
const roots = Array.from(tenantMap.values()).filter(
(n) => !n.parentId || n.parentId === n.id,
);
return { currentBase: null, subTree: roots };
}

View File

@@ -308,7 +308,9 @@ test("org chart defaults to hanmac family when public sector group is listed fir
await page.goto("/chart");
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(page.getByRole("button", { name: "조직: 한맥가족" })).toBeVisible();
await expect(
page.getByRole("button", { name: "조직: 한맥가족" }),
).toBeVisible();
await expect(svg.getByText("한맥가족", { exact: true })).toBeVisible();
await expect(svg.getByText("삼안", { exact: true })).toBeVisible();
await expect(svg.getByText("공공기관", { exact: true })).toHaveCount(0);

View File

@@ -515,10 +515,13 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({
contentType: "application/json",
body: JSON.stringify({
tenants: [
{
...tenant("hanmac-family-id", "한맥가족", "hanmac-family"),
type: "COMPANY_GROUP",
},
tenant(
"hanmac-family-id",
"한맥가족",
"hanmac-family",
"hanmac-family-id",
"COMPANY_GROUP",
),
tenant("saman-id", "삼안", "saman", "hanmac-family-id", "COMPANY"),
tenant("saman-platform-id", "플랫폼팀", "saman-platform", "saman-id"),
],
@@ -562,6 +565,57 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({
await expect(svg.getByText(/Saman Descendant User/)).toBeVisible();
});
test("org chart logs authenticated snapshot failures with actionable diagnostics", async ({
page,
}) => {
const consoleMessages: string[] = [];
page.on("console", async (message) => {
if (message.type() !== "error") return;
const values = await Promise.all(
message.args().map((arg) => arg.jsonValue().catch(() => "")),
);
consoleMessages.push(JSON.stringify(values));
});
await page.addInitScript(() => {
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "saman-id");
});
await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
await route.fulfill({
contentType: "application/json",
status: 503,
body: JSON.stringify({
code: "service_unavailable",
error: "tenant root traversal failed",
}),
});
});
await page.goto("/chart");
await expect(
page.getByText(
"조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.",
),
).toBeVisible();
await expect(
page.getByText("조직도를 불러올 수 없거나 만료된 링크입니다."),
).toHaveCount(0);
await expect
.poll(() =>
consoleMessages.some(
(message) =>
message.includes("[orgfront] Org chart load failed") &&
message.includes("service_unavailable") &&
message.includes("saman-id") &&
message.includes("/v1/admin/orgchart/snapshot"),
),
)
.toBe(true);
});
test("org chart places GPDTDC representative users on visible leaf appointments", async ({
page,
}) => {