forked from baron/baron-sso
Merge pull request 'temp-branch' (#464) from temp-branch into dev
Reviewed-on: baron/baron-sso#464
This commit is contained in:
@@ -19,6 +19,12 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["dist", "node_modules", "tsconfig*.json"]
|
"ignore": [
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"tsconfig*.json",
|
||||||
|
"test-results",
|
||||||
|
"playwright-report"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
261
adminfront/tests/users.spec.ts
Normal file
261
adminfront/tests/users.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("User Management", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const authority = "http://localhost:5000/oidc";
|
||||||
|
const client_id = "adminfront";
|
||||||
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
|
const authData = {
|
||||||
|
id_token: "fake-id-token",
|
||||||
|
access_token: "fake-token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
scope: "openid profile email",
|
||||||
|
profile: {
|
||||||
|
sub: "admin-user",
|
||||||
|
name: "Admin",
|
||||||
|
email: "admin@test.com",
|
||||||
|
role: "super_admin",
|
||||||
|
},
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
|
window.localStorage.setItem("admin_session", "fake-token");
|
||||||
|
window.localStorage.setItem("locale", "ko");
|
||||||
|
window.localStorage.setItem("oidc.state", "dummy");
|
||||||
|
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/oidc/**", async (route) => {
|
||||||
|
if (route.request().url().includes("/.well-known/openid-configuration")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
issuer: "http://localhost:5000/oidc",
|
||||||
|
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||||
|
token_endpoint: "http://localhost:5000/oidc/token",
|
||||||
|
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
|
||||||
|
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
const method = route.request().method();
|
||||||
|
|
||||||
|
if (url.includes("/user/me")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "admin-user",
|
||||||
|
name: "Admin",
|
||||||
|
email: "admin@test.com",
|
||||||
|
role: "super_admin",
|
||||||
|
manageableTenants: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/tenants") && method === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "t-1",
|
||||||
|
slug: "test-tenant",
|
||||||
|
name: "Test Tenant",
|
||||||
|
config: {
|
||||||
|
userSchema: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/users/u-1") && method === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "u-1",
|
||||||
|
name: "John Doe",
|
||||||
|
email: "john@test.com",
|
||||||
|
loginId: "johndoe",
|
||||||
|
tenantSlug: "test-tenant",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/users") && method === "POST") {
|
||||||
|
// Parse request payload to simulate validation checks
|
||||||
|
const postData = route.request().postDataJSON();
|
||||||
|
if (postData && postData.loginId === "existing_user") {
|
||||||
|
// Simulate a backend conflict error (409) for an existing loginId
|
||||||
|
return route.fulfill({
|
||||||
|
status: 409,
|
||||||
|
json: {
|
||||||
|
error: "이미 존재하는 로그인 ID 입니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Mock successful user creation
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "new-user-id",
|
||||||
|
name: "New User",
|
||||||
|
email: "newuser@test.com",
|
||||||
|
loginId: postData?.loginId || "newuser123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/users/u-1") && method === "PUT") {
|
||||||
|
// Parse request payload
|
||||||
|
const postData = route.request().postDataJSON();
|
||||||
|
if (postData && postData.loginId === "existing_user") {
|
||||||
|
// Simulate a backend conflict error (409) for an existing loginId
|
||||||
|
return route.fulfill({
|
||||||
|
status: 409,
|
||||||
|
json: {
|
||||||
|
error: "이미 존재하는 로그인 ID 입니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock successful user update
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "u-1",
|
||||||
|
name: "John Doe Updated",
|
||||||
|
email: "john@test.com",
|
||||||
|
loginId: postData?.loginId || "johndoe_updated",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("/admin/users") && method === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "u-1",
|
||||||
|
name: "John Doe",
|
||||||
|
email: "john@test.com",
|
||||||
|
loginId: "johndoe",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({ json: { items: [], total: 0 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should successfully edit a user's Login ID", async ({ page }) => {
|
||||||
|
await page.goto("/users/u-1");
|
||||||
|
|
||||||
|
// Wait for the form to load with the existing login ID
|
||||||
|
const loginIdInput = page.locator('input[id="loginId"]');
|
||||||
|
await expect(loginIdInput).toBeVisible();
|
||||||
|
await expect(loginIdInput).toHaveValue("johndoe");
|
||||||
|
|
||||||
|
// Change the Login ID
|
||||||
|
await loginIdInput.fill("johndoe_updated");
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const saveButton = page.getByRole("button", {
|
||||||
|
name: /변경사항 저장|Save/i,
|
||||||
|
});
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Check for success message
|
||||||
|
await expect(
|
||||||
|
page.getByText(/사용자 정보가 수정되었습니다/i).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show conflict error when updating to an existing Login ID", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/users/u-1");
|
||||||
|
|
||||||
|
const loginIdInput = page.locator('input[id="loginId"]');
|
||||||
|
await expect(loginIdInput).toBeVisible();
|
||||||
|
|
||||||
|
// Enter a login ID that triggers our mock conflict error
|
||||||
|
await loginIdInput.fill("existing_user");
|
||||||
|
|
||||||
|
const saveButton = page.getByRole("button", {
|
||||||
|
name: /변경사항 저장|Save/i,
|
||||||
|
});
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Check for the specific conflict error message from the backend mock
|
||||||
|
await expect(
|
||||||
|
page.getByText(/이미 존재하는 로그인 ID 입니다/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should successfully create a new user with a Login ID", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/users/new");
|
||||||
|
|
||||||
|
// Ensure the page title is loaded
|
||||||
|
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Fill required fields
|
||||||
|
await page.locator('input[name="name"]').fill("New User");
|
||||||
|
await page.locator('input[name="email"]').fill("newuser@test.com");
|
||||||
|
|
||||||
|
// Fill Login ID
|
||||||
|
const loginIdInput = page.locator('input[name="loginId"]');
|
||||||
|
await loginIdInput.fill("newuser123");
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const createButton = page.getByRole("button", { name: /생성/i });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// Assuming successful creation redirects back to the user list
|
||||||
|
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show conflict error when creating with an existing Login ID", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/users/new");
|
||||||
|
|
||||||
|
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Fill required fields
|
||||||
|
await page.locator('input[name="name"]').fill("New User");
|
||||||
|
await page.locator('input[name="email"]').fill("newuser@test.com");
|
||||||
|
|
||||||
|
// Fill Login ID that triggers the mock conflict error
|
||||||
|
const loginIdInput = page.locator('input[name="loginId"]');
|
||||||
|
await loginIdInput.fill("existing_user");
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
const createButton = page.getByRole("button", { name: /생성/i });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// Check for the specific conflict error message from the backend mock
|
||||||
|
await expect(
|
||||||
|
page.getByText(/이미 존재하는 로그인 ID 입니다/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -460,7 +460,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
} else {
|
} else {
|
||||||
// If companyCode provided but not found, we should probably reject if we want strictness,
|
// If companyCode provided but not found, we should probably reject if we want strictness,
|
||||||
// or just treat as GENERAL user. Given the risk "존재하지 않는 테넌트도 저장됨", we should reject.
|
// or just treat as GENERAL user. Given the risk "존재하지 않는 테넌트도 저장됨", we should reject.
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "Invalid company code.")
|
return errorJSON(c, fiber.StatusBadRequest, "해당하는 가족사(테넌트)를 찾을 수 없습니다.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +473,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
normalizedPhone = "+" + normalizedPhone
|
normalizedPhone = "+" + normalizedPhone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
|
||||||
|
|
||||||
// IDP에 전달할 BrokerUser 스키마 구성
|
// IDP에 전달할 BrokerUser 스키마 구성
|
||||||
attributes := map[string]interface{}{
|
attributes := map[string]interface{}{
|
||||||
"department": req.Department,
|
"department": req.Department,
|
||||||
@@ -517,7 +519,8 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
if strings.Contains(err.Error(), "already exists") {
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
return errorJSON(c, fiber.StatusConflict, "User already exists")
|
return errorJSON(c, fiber.StatusConflict, "User already exists")
|
||||||
}
|
}
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to create user")
|
// Include the actual error message in the response for debugging
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Cleanup Redis
|
// 4. Cleanup Redis
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
var res map[string]interface{}
|
var res map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&res)
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
assert.Equal(t, "Invalid company code.", res["error"])
|
assert.Equal(t, "해당하는 가족사(테넌트)를 찾을 수 없습니다.", res["error"])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Active Company Code", func(t *testing.T) {
|
t.Run("Active Company Code", func(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -45,7 +46,7 @@ func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Ten
|
|||||||
|
|
||||||
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", strings.ToLower(slug)).First(&tenant).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &tenant, nil
|
return &tenant, nil
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["dist", "node_modules", "tsconfig*.json"]
|
"ignore": [
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"tsconfig*.json",
|
||||||
|
"test-results",
|
||||||
|
"playwright-report"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
} from "../../components/ui/avatar";
|
} from "../../components/ui/avatar";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import ProfileTenantSwitcher from "./ProfileTenantSwitcher";
|
|
||||||
import { fetchMe } from "../auth/authApi";
|
import { fetchMe } from "../auth/authApi";
|
||||||
|
import ProfileTenantSwitcher from "./ProfileTenantSwitcher";
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import '../../../core/i18n/locale_utils.dart';
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../core/ui/toast_service.dart';
|
||||||
|
|
||||||
class SignupScreen extends StatefulWidget {
|
class SignupScreen extends StatefulWidget {
|
||||||
const SignupScreen({super.key});
|
const SignupScreen({super.key});
|
||||||
@@ -328,24 +329,29 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
String eStr = e.toString().toLowerCase();
|
String eStr = e.toString().toLowerCase();
|
||||||
setState(() {
|
setState(() {
|
||||||
if (eStr.contains('uppercase')) {
|
if (eStr.contains('password') && eStr.contains('uppercase')) {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.password.uppercase_required',
|
'msg.userfront.signup.password.uppercase_required',
|
||||||
);
|
);
|
||||||
} else if (eStr.contains('lowercase')) {
|
} else if (eStr.contains('password') && eStr.contains('lowercase')) {
|
||||||
_passwordError = tr(
|
_passwordError = tr(
|
||||||
'msg.userfront.signup.password.lowercase_required',
|
'msg.userfront.signup.password.lowercase_required',
|
||||||
);
|
);
|
||||||
} else if (eStr.contains('digit') || eStr.contains('number')) {
|
} else if (eStr.contains('password') &&
|
||||||
|
(eStr.contains('digit') || eStr.contains('number'))) {
|
||||||
_passwordError = tr('msg.userfront.signup.password.number_required');
|
_passwordError = tr('msg.userfront.signup.password.number_required');
|
||||||
} else if (eStr.contains('symbol') || eStr.contains('special')) {
|
} else if (eStr.contains('password') &&
|
||||||
|
(eStr.contains('symbol') || eStr.contains('special'))) {
|
||||||
_passwordError = tr('msg.userfront.signup.password.symbol_required');
|
_passwordError = tr('msg.userfront.signup.password.symbol_required');
|
||||||
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
|
} else if (eStr.contains('password') &&
|
||||||
|
(eStr.contains('length') || eStr.contains('12 characters'))) {
|
||||||
_passwordError = tr('msg.userfront.signup.password.length_required');
|
_passwordError = tr('msg.userfront.signup.password.length_required');
|
||||||
} else {
|
} else {
|
||||||
_passwordError = tr(
|
_showError(
|
||||||
'msg.userfront.signup.failed',
|
tr(
|
||||||
params: {'error': e.toString()},
|
'msg.userfront.signup.failed',
|
||||||
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -354,6 +360,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastService.error(message);
|
||||||
|
}
|
||||||
|
|
||||||
void _showSuccessDialog() {
|
void _showSuccessDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1488,8 +1499,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
.replaceAll('Exception: ', ''),
|
.replaceAll('Exception: ', ''),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted)
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('중복 확인'),
|
child: const Text('중복 확인'),
|
||||||
|
|||||||
Reference in New Issue
Block a user