From 600961f33d349bafa3fccb72baf0449bb49a4646 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 25 Feb 2026 14:17:45 +0900 Subject: [PATCH] =?UTF-8?q?slug=20=EB=AA=85=EC=B9=AD=20=ED=95=9C=EA=B8=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/components/ui/card.test.tsx | 35 +++++++++ adminfront/src/components/ui/input.test.tsx | 28 +++++++ adminfront/src/components/ui/label.test.tsx | 27 +++++++ adminfront/src/lib/i18n.test.ts | 33 +++++++++ adminfront/src/lib/utils.test.ts | 13 ++++ adminfront/tests/auth.spec.ts | 82 +++++++++++++++++++++ adminfront/tests/tenants.spec.ts | 75 +++++++++++++++++++ backend/internal/handler/tenant_handler.go | 25 +++---- backend/internal/utils/slug.go | 44 +++++++++++ backend/internal/utils/slug_test.go | 59 +++++++++++++++ 10 files changed, 406 insertions(+), 15 deletions(-) create mode 100644 adminfront/src/components/ui/card.test.tsx create mode 100644 adminfront/src/components/ui/input.test.tsx create mode 100644 adminfront/src/components/ui/label.test.tsx create mode 100644 adminfront/src/lib/i18n.test.ts create mode 100644 adminfront/src/lib/utils.test.ts create mode 100644 adminfront/tests/auth.spec.ts create mode 100644 adminfront/tests/tenants.spec.ts diff --git a/adminfront/src/components/ui/card.test.tsx b/adminfront/src/components/ui/card.test.tsx new file mode 100644 index 00000000..4bde79e9 --- /dev/null +++ b/adminfront/src/components/ui/card.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./card"; + +describe("Card Component", () => { + it("renders card structure correctly", () => { + render( + + + Card Title + Card Description + + Card Content + Card Footer + , + ); + + expect(screen.getByText("Card Title")).toBeInTheDocument(); + expect(screen.getByText("Card Description")).toBeInTheDocument(); + expect(screen.getByText("Card Content")).toBeInTheDocument(); + expect(screen.getByText("Card Footer")).toBeInTheDocument(); + }); + + it("applies custom className to Card", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("custom-card"); + }); +}); diff --git a/adminfront/src/components/ui/input.test.tsx b/adminfront/src/components/ui/input.test.tsx new file mode 100644 index 00000000..011f8404 --- /dev/null +++ b/adminfront/src/components/ui/input.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { Input } from "./input"; + +describe("Input Component", () => { + it("renders correctly", () => { + render(); + expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument(); + }); + + it("handles value changes", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText("Enter text"); + + await user.type(input, "Hello"); + expect(onChange).toHaveBeenCalled(); + expect(input).toHaveValue("Hello"); + }); + + it("is disabled when the disabled prop is passed", () => { + render(); + const input = screen.getByRole("textbox"); + expect(input).toBeDisabled(); + }); +}); diff --git a/adminfront/src/components/ui/label.test.tsx b/adminfront/src/components/ui/label.test.tsx new file mode 100644 index 00000000..cfde252a --- /dev/null +++ b/adminfront/src/components/ui/label.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Label } from "./label"; + +describe("Label Component", () => { + it("renders correctly with children", () => { + render(); + expect(screen.getByText("Username")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + render(); + const label = screen.getByText("Password"); + expect(label).toHaveClass("custom-label"); + }); + + it("is associated with an input via htmlFor", () => { + render( + <> + + + + ); + const label = screen.getByText("Label Text"); + expect(label).toHaveAttribute("for", "test-input"); + }); +}); diff --git a/adminfront/src/lib/i18n.test.ts b/adminfront/src/lib/i18n.test.ts new file mode 100644 index 00000000..fbfd4e13 --- /dev/null +++ b/adminfront/src/lib/i18n.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { t } from "./i18n"; + +describe("i18n utility", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + it("returns fallback if key not found", () => { + expect(t("non.existent.key", "Fallback")).toBe("Fallback"); + }); + + it("returns key if fallback not provided and key not found", () => { + expect(t("non.existent.key")).toBe("non.existent.key"); + }); + + it("replaces variables in template", () => { + expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe("Hello World"); + }); + + it("respects locale in localStorage", () => { + window.localStorage.setItem("locale", "en"); + // We expect some key that exists in en.toml + // Let's use a common one or a fallback if we don't know the content + expect(t("ui.common.save", "Save")).toBe("Save"); + }); + + it("defaults to ko if no locale set and browser language is ko", () => { + vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR'); + expect(t("ui.common.save", "저장")).toBe("저장"); + }); +}); diff --git a/adminfront/src/lib/utils.test.ts b/adminfront/src/lib/utils.test.ts new file mode 100644 index 00000000..a5ad7f08 --- /dev/null +++ b/adminfront/src/lib/utils.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { cn } from "./utils"; + +describe("cn utility", () => { + it("merges class names correctly", () => { + expect(cn("a", "b")).toBe("a b"); + expect(cn("a", { b: true, c: false })).toBe("a b"); + }); + + it("handles tailwind class conflicts", () => { + expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4"); + }); +}); diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts new file mode 100644 index 00000000..1ce4b243 --- /dev/null +++ b/adminfront/tests/auth.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + // Mock OIDC configuration + await page.route('**/oidc/.well-known/openid-configuration', async route => { + await route.fulfill({ + json: { + issuer: "http://localhost:5000/oidc", + authorization_endpoint: "http://localhost:5000/oidc/auth", + token_endpoint: "http://localhost:5000/oidc/token", + jwks_uri: "http://localhost:5000/oidc/jwks", + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"] + } + }); + }); + }); + + test('should redirect unauthorized users to login page', async ({ page }) => { + await page.goto('/'); + // Should be redirected to /login + await expect(page).toHaveURL(/\/login/); + await expect(page.locator('h1')).toContainText('Baron SSO'); + }); + + test('should allow access to dashboard when authenticated', async ({ page }) => { + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: 'fake-token', + token_type: 'Bearer', + profile: { + sub: 'admin-user', + name: 'Admin User', + email: 'admin@example.com' + }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + await page.goto('/'); + + // Wait for the auth loading to finish + await expect(page.locator('.animate-spin')).not.toBeVisible(); + + // Should be on the dashboard/overview + await expect(page.locator('aside')).toBeVisible(); + await expect(page.locator('h1')).toContainText('Admin Control'); + }); + + test('should logout and redirect to login page', async ({ page }) => { + // Start authenticated + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: 'fake-token', + token_type: 'Bearer', + profile: { sub: 'admin-user', name: 'Admin' }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + await page.goto('/'); + await expect(page.locator('aside')).toBeVisible(); + + // Mock window.confirm + page.on('dialog', dialog => dialog.accept()); + + // Click logout button (label: ui.admin.nav.logout) + await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); + + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts new file mode 100644 index 00000000..57ce51cb --- /dev/null +++ b/adminfront/tests/tenants.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Tenants Management', () => { + test.beforeEach(async ({ page }) => { + // Authenticate + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: 'fake-token', + token_type: 'Bearer', + profile: { sub: 'admin-user', name: 'Admin User', email: 'admin@example.com' }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + // Mock OIDC config to avoid redirects + await page.route('**/oidc/.well-known/openid-configuration', async route => { + await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + }); + }); + + test('should list tenants', async ({ page }) => { + await page.route('**/api/v1/admin/tenants*', async route => { + await route.fulfill({ + json: { + items: [ + { id: '1', name: 'Tenant A', slug: 'tenant-a', status: 'active', type: 'COMPANY', updatedAt: new Date().toISOString() }, + ], + total: 1, + limit: 1000, + offset: 0 + } + }); + }); + + await page.goto('/tenants'); + await expect(page.locator('h2')).toContainText('테넌트 목록'); + await expect(page.locator('table')).toContainText('Tenant A'); + }); + + test('should create a new tenant', async ({ page }) => { + // Mock GET for list (empty) and for parents + await page.route('**/api/v1/admin/tenants*', async route => { + if (route.request().method() === 'GET') { + await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 } }); + } else if (route.request().method() === 'POST') { + await route.fulfill({ + json: { id: '2', name: 'New Tenant', slug: 'new-tenant', status: 'active', type: 'COMPANY' } + }); + } + }); + + await page.goto('/tenants/new'); + + await page.fill('input >> nth=0', 'New Tenant'); + await page.fill('input >> nth=1', 'new-tenant'); + await page.fill('textarea', 'Description'); + + await page.click('button:has-text("생성")'); + + await expect(page).toHaveURL(/\/tenants$/); + }); + + test('should show validation error on empty name', async ({ page }) => { + await page.goto('/tenants/new'); + const submitBtn = page.locator('button:has-text("생성")'); + await expect(submitBtn).toBeDisabled(); + + await page.fill('input >> nth=0', 'Valid Name'); + await expect(submitBtn).not.toBeDisabled(); + }); +}); diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 1e1182a2..737e0105 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -4,6 +4,7 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "errors" "strings" "time" @@ -166,9 +167,15 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) } - slug := normalizeTenantSlug(req.Slug) + slug := req.Slug if slug == "" { - slug = normalizeTenantSlug(name) + slug = utils.GenerateUniqueSlug(name, func(s string) bool { + var count int64 + h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", s).Count(&count) + return count > 0 + }) + } else { + slug = utils.GenerateSlug(slug) } if slug == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug is required"}) @@ -240,7 +247,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { tenant.Name = name } if req.Slug != nil { - slug := normalizeTenantSlug(*req.Slug) + slug := utils.GenerateSlug(*req.Slug) if slug == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug cannot be empty"}) } @@ -436,18 +443,6 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { } } -func normalizeTenantSlug(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - value = strings.ReplaceAll(value, " ", "-") - var b strings.Builder - for _, r := range value { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } - } - return strings.Trim(b.String(), "-") -} - func normalizeTenantStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { diff --git a/backend/internal/utils/slug.go b/backend/internal/utils/slug.go index b736a902..6c9b4ba3 100644 --- a/backend/internal/utils/slug.go +++ b/backend/internal/utils/slug.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "regexp" "strings" ) @@ -75,3 +76,46 @@ func ValidateSlug(slug string) (bool, string) { return true, "" } + +// GenerateSlug generates a base slug from a given string. +// It removes special characters, replaces spaces with hyphens, and converts to lowercase. +func GenerateSlug(name string) string { + // Convert to lowercase + s := strings.ToLower(strings.TrimSpace(name)) + + // Replace non-alphanumeric characters (including spaces) with a hyphen + re := regexp.MustCompile(`[^a-z0-9]+`) + s = re.ReplaceAllString(s, "-") + + // Remove leading and trailing hyphens + s = strings.Trim(s, "-") + + // Handle empty slug + if s == "" { + s = "tenant" + } + + // Truncate to maximum length of 32 (reserving space for suffixes) + if len(s) > 25 { + s = s[:25] + s = strings.TrimSuffix(s, "-") + } + + return s +} + +// GenerateUniqueSlug generates a unique slug by appending a suffix if the base slug exists. +// It takes the base name and a checker function that returns true if the slug already exists. +func GenerateUniqueSlug(name string, exists func(string) bool) string { + baseSlug := GenerateSlug(name) + + slug := baseSlug + counter := 1 + + for reservedSlugs[slug] || exists(slug) { + slug = fmt.Sprintf("%s-%d", baseSlug, counter) + counter++ + } + + return slug +} diff --git a/backend/internal/utils/slug_test.go b/backend/internal/utils/slug_test.go index 15dad8ef..fd6f400d 100644 --- a/backend/internal/utils/slug_test.go +++ b/backend/internal/utils/slug_test.go @@ -54,3 +54,62 @@ func TestValidateSlug_Format(t *testing.T) { }) } } + +func TestGenerateSlug(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {"Hello World", "hello-world"}, + {"My Company!@#", "my-company"}, + {"---Test---", "test"}, + {" Spaces ", "spaces"}, + {"A VERY LONG NAME THAT EXCEEDS THIRTY TWO CHARACTERS", "a-very-long-name-that-exc"}, + {"한글 테스트", "tenant"}, // Non-ascii characters will be replaced by hyphens and trimmed to empty, then fallback to "tenant" + {"Test 한글 Mix", "test-mix"}, + {"", "tenant"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slug := GenerateSlug(tt.name) + assert.Equal(t, tt.expected, slug) + // Ensure generated slug is valid (unless it's reserved like "slug" wasn't reserved, but let's check format) + if !reservedSlugs[slug] { + valid, _ := ValidateSlug(slug) + assert.True(t, valid, "Generated slug should be valid format") + } + }) + } +} + +func TestGenerateUniqueSlug(t *testing.T) { + existingSlugs := map[string]bool{ + "my-company": true, + "my-company-1": true, + "test": true, + } + + existsFunc := func(slug string) bool { + return existingSlugs[slug] + } + + tests := []struct { + name string + expected string + }{ + {"My Company", "my-company-2"}, + {"Test", "test-1"}, + {"New Company", "new-company"}, + {"admin", "admin-1"}, // "admin" is reserved, so it should append suffix + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slug := GenerateUniqueSlug(tt.name, existsFunc) + assert.Equal(t, tt.expected, slug) + valid, _ := ValidateSlug(slug) + assert.True(t, valid, "Generated unique slug should be valid") + }) + } +}