1
0
forked from baron/baron-sso

org chart 자동로그인 보완. seed-tenant 삭제불가 조치

This commit is contained in:
2026-04-30 17:02:24 +09:00
parent 6eb4c293ff
commit 3dcdd97882
13 changed files with 490 additions and 32 deletions

View File

@@ -57,6 +57,7 @@ import {
parseTenantCSV, parseTenantCSV,
serializeTenantImportCSV, serializeTenantImportCSV,
} from "../utils/tenantCsvImport"; } from "../utils/tenantCsvImport";
import { isSeedTenant } from "../utils/protectedTenants";
const tenantCSVTemplate = const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain\n"; "name,type,parent_tenant_slug,slug,memo,email_domain\n";
@@ -206,36 +207,48 @@ function TenantListPage() {
); );
}, [allTenants, search]); }, [allTenants, search]);
const deletableTenants = React.useMemo(
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
[tenants],
);
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
setSelectedIds(tenants.map((t) => t.id)); setSelectedIds(deletableTenants.map((t) => t.id));
} else { } else {
setSelectedIds([]); setSelectedIds([]);
} }
}; };
const handleSelect = (id: string, checked: boolean) => { const handleSelect = (tenant: TenantSummary, checked: boolean) => {
if (isSeedTenant(tenant)) {
return;
}
if (checked) { if (checked) {
setSelectedIds((prev) => [...prev, id]); setSelectedIds((prev) => [...prev, tenant.id]);
} else { } else {
setSelectedIds((prev) => prev.filter((i) => i !== id)); setSelectedIds((prev) => prev.filter((i) => i !== tenant.id));
} }
}; };
const handleDeleteBulk = () => { const handleDeleteBulk = () => {
if (selectedIds.length === 0) return; if (selectedIds.length === 0) return;
const deletableIds = selectedIds.filter((id) =>
deletableTenants.some((tenant) => tenant.id === id),
);
if (deletableIds.length === 0) return;
if ( if (
!window.confirm( !window.confirm(
t( t(
"msg.admin.tenants.delete_bulk_confirm", "msg.admin.tenants.delete_bulk_confirm",
"선택한 {{count}}개 테넌트를 삭제할까요?", "선택한 {{count}}개 테넌트를 삭제할까요?",
{ count: selectedIds.length }, { count: deletableIds.length },
), ),
) )
) { ) {
return; return;
} }
deleteBulkMutation.mutate(selectedIds); deleteBulkMutation.mutate(deletableIds);
}; };
const handleTemplateDownload = () => { const handleTemplateDownload = () => {
@@ -312,6 +325,10 @@ function TenantListPage() {
}; };
const handleDelete = (tenantId: string, tenantName: string) => { const handleDelete = (tenantId: string, tenantName: string) => {
const tenant = allTenants.find((item) => item.id === tenantId);
if (tenant && isSeedTenant(tenant)) {
return;
}
if ( if (
!window.confirm( !window.confirm(
t( t(
@@ -473,7 +490,8 @@ function TenantListPage() {
<Checkbox <Checkbox
checked={ checked={
tenants.length > 0 && tenants.length > 0 &&
selectedIds.length === tenants.length deletableTenants.length > 0 &&
selectedIds.length === deletableTenants.length
} }
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleSelectAll(!!checked) handleSelectAll(!!checked)
@@ -529,13 +547,17 @@ function TenantListPage() {
)} )}
{tenants.map((tenant) => ( {tenants.map((tenant) => (
<TableRow key={tenant.id}> <TableRow key={tenant.id}>
<TableCell> <TableCell className="text-center">
<Checkbox {isSeedTenant(tenant) ? (
checked={selectedIds.includes(tenant.id)} <span className="inline-block h-4 w-4" />
onCheckedChange={(checked) => ) : (
handleSelect(tenant.id, !!checked) <Checkbox
} checked={selectedIds.includes(tenant.id)}
/> onCheckedChange={(checked) =>
handleSelect(tenant, !!checked)
}
/>
)}
</TableCell> </TableCell>
<TableCell <TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground" className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
@@ -544,7 +566,17 @@ function TenantListPage() {
{tenant.id} {tenant.id}
</TableCell> </TableCell>
<TableCell className="font-semibold"> <TableCell className="font-semibold">
{tenant.name} <div className="flex flex-wrap items-center gap-2">
<span>{tenant.name}</span>
{isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]">
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
</Badge>
)}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
@@ -598,7 +630,17 @@ function TenantListPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)} onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending} disabled={
deleteMutation.isPending || isSeedTenant(tenant)
}
title={
isSeedTenant(tenant)
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
> >
<Trash2 size={14} /> <Trash2 size={14} />
{t("ui.common.delete", "삭제")} {t("ui.common.delete", "삭제")}

View File

@@ -28,6 +28,7 @@ import {
formatDomainConflictMessage, formatDomainConflictMessage,
type ServerDomainConflict, type ServerDomainConflict,
} from "../utils/domainTags"; } from "../utils/domainTags";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() { export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>(); const { tenantId } = useParams<{ tenantId: string }>();
@@ -154,8 +155,14 @@ export function TenantProfilePage() {
?.response?.data?.error; ?.response?.data?.error;
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>) const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
?.response?.data?.error; ?.response?.data?.error;
const isProtectedSeedTenant = tenantQuery.data
? isSeedTenant(tenantQuery.data)
: false;
const handleDelete = () => { const handleDelete = () => {
if (isProtectedSeedTenant) {
return;
}
if ( if (
window.confirm( window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", { t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
@@ -335,7 +342,15 @@ export function TenantProfilePage() {
<Button <Button
variant="outline" variant="outline"
onClick={handleDelete} onClick={handleDelete}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending || isProtectedSeedTenant}
title={
isProtectedSeedTenant
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
> >
<Trash2 size={16} /> <Trash2 size={16} />
{t("ui.common.delete", "삭제")} {t("ui.common.delete", "삭제")}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => {
expect(getSeedTenantSlugs()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]),
);
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport";
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
// eslint-disable-next-line import/no-unresolved
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
const seedTenantSlugs = new Set(
parseTenantCSV(seedTenantCSVRaw)
.map((row) => row.slug.trim().toLowerCase())
.filter(Boolean),
);
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
}
export function getSeedTenantSlugs(): string[] {
return Array.from(seedTenantSlugs);
}

View File

@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
const tenants = [
{
id: "seed-hanmac",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
description: "한맥가족 기본 루트 테넌트",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "normal-tenant",
name: "일반 테넌트",
slug: "normal-tenant",
type: "COMPANY",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
test.describe("Seed tenant protection", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants/seed-hanmac")) {
return route.fulfill({ json: tenants[0], headers });
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: {
items: tenants,
total: tenants.length,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.fulfill({ json: {}, headers });
});
});
test("removes selection and disables delete action for seed tenants in the list", async ({
page,
}) => {
await page.goto("/tenants");
const seedRow = page.getByRole("row", { name: /한맥가족/ });
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
await expect(seedRow.getByText("초기 설정")).toBeVisible();
await expect(seedRow.getByRole("button", { name: /삭제/ })).toBeDisabled();
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
await expect(
normalRow.getByRole("button", { name: /삭제/ }),
).toBeEnabled();
});
test("disables delete action on seed tenant profile", async ({ page }) => {
await page.goto("/tenants/seed-hanmac");
await expect(page.getByRole("heading", { name: "한맥가족" })).toBeVisible();
await expect(page.getByRole("button", { name: "삭제" })).toBeDisabled();
});
});

View File

@@ -129,6 +129,36 @@ func loadSeedTenantConfigs() ([]InitialTenantConfig, error) {
return configs, nil return configs, nil
} }
func SeedTenantSlugSet() (map[string]bool, error) {
configs, err := loadSeedTenantConfigs()
if err != nil {
return nil, err
}
slugs := make(map[string]bool, len(configs))
for _, config := range configs {
slug := strings.TrimSpace(strings.ToLower(config.Slug))
if slug != "" {
slugs[slug] = true
}
}
return slugs, nil
}
func IsSeedTenantSlug(slug string) bool {
normalized := strings.TrimSpace(strings.ToLower(slug))
if normalized == "" {
return false
}
slugs, err := SeedTenantSlugSet()
if err != nil {
slog.Warn("[Bootstrap] Failed to load seed tenant slug set", "error", err)
return false
}
return slugs[normalized]
}
func findSeedTenantCSVPath() (string, error) { func findSeedTenantCSVPath() (string, error) {
if configured := strings.TrimSpace(os.Getenv(seedTenantCSVPathEnv)); configured != "" { if configured := strings.TrimSpace(os.Getenv(seedTenantCSVPathEnv)); configured != "" {
return configured, nil return configured, nil

View File

@@ -82,3 +82,21 @@ func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains) t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
} }
} }
func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"Root,COMPANY_GROUP,,protected-root,Root memo,\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
t.Setenv(seedTenantCSVPathEnv, path)
if !IsSeedTenantSlug("protected-root") {
t.Fatal("protected-root must be detected as seed tenant")
}
if IsSeedTenantSlug("normal-tenant") {
t.Fatal("normal-tenant must not be detected as seed tenant")
}
}

View File

@@ -7420,6 +7420,9 @@ func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]inte
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string { func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string {
clientID = strings.TrimSpace(clientID) clientID = strings.TrimSpace(clientID)
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" { if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
if clientID == "orgfront" {
return ensureOrgfrontAutoLoginURL(metadataURL)
}
return metadataURL return metadataURL
} }
@@ -7434,13 +7437,29 @@ func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{
} }
case "orgfront": case "orgfront":
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ORGFRONT_URL")), "/"); value != "" { if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ORGFRONT_URL")), "/"); value != "" {
return value + "/login" return value + "/login?auto=1"
} }
} }
return "" return ""
} }
func ensureOrgfrontAutoLoginURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
if strings.TrimRight(parsed.Path, "/") != "/login" {
return rawURL
}
query := parsed.Query()
if query.Get("auto") != "1" {
query.Set("auto", "1")
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string { func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string {
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) { if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
return "" return ""

View File

@@ -197,7 +197,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
} }
} }
assert.True(t, orgfrontItem.AutoLoginSupported) assert.True(t, orgfrontItem.AutoLoginSupported)
assert.Equal(t, "http://localhost:5175/login", orgfrontItem.AutoLoginURL) assert.Equal(t, "http://localhost:5175/login?auto=1", orgfrontItem.AutoLoginURL)
assert.Equal(t, orgfrontItem.AutoLoginURL, orgfrontItem.InitURL) assert.Equal(t, orgfrontItem.AutoLoginURL, orgfrontItem.InitURL)
} }

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
@@ -28,6 +29,23 @@ type TenantHandler struct {
SharedLink service.SharedLinkService SharedLink service.SharedLinkService
} }
func seedTenantDeleteError(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
}
func seedTenantSlugsForDeleteGuard() []string {
slugs, err := bootstrap.SeedTenantSlugSet()
if err != nil {
return nil
}
result := make([]string, 0, len(slugs))
for slug := range slugs {
result = append(result, slug)
}
return result
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler { func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler {
return &TenantHandler{ return &TenantHandler{
DB: db, DB: db,
@@ -1045,7 +1063,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
} }
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
var req struct { var req struct {
Name *string `json:"name"` Name *string `json:"name"`
Type *string `json:"type"` Type *string `json:"type"`
@@ -1192,6 +1209,9 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
} }
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
if bootstrap.IsSeedTenantSlug(tenant.Slug) {
return seedTenantDeleteError(c)
}
// Rename slug to release it for reuse before soft delete // Rename slug to release it for reuse before soft delete
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405") deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
@@ -1502,6 +1522,20 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion") return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion")
} }
protectedSlugs := seedTenantSlugsForDeleteGuard()
if len(protectedSlugs) > 0 {
var protectedCount int64
if err := h.DB.Model(&domain.Tenant{}).
Where("id IN ?", req.IDs).
Where("slug IN ?", protectedSlugs).
Count(&protectedCount).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if protectedCount > 0 {
return seedTenantDeleteError(c)
}
}
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil { if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }

View File

@@ -0,0 +1,159 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/testcontainers/testcontainers-go"
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
gorm_postgres "gorm.io/driver/postgres"
"gorm.io/gorm"
)
func newTenantHandlerSeedDeleteDB(t *testing.T) *gorm.DB {
t.Helper()
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
ctx := context.Background()
postgresContainer, err := postgres_module.Run(ctx,
"postgres:16-alpine",
postgres_module.WithDatabase("testdb"),
postgres_module.WithUsername("user"),
postgres_module.WithPassword("password"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second)),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
t.Cleanup(func() {
if err := postgresContainer.Terminate(ctx); err != nil {
log.Printf("failed to terminate postgres container: %v", err)
}
})
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get postgres connection string: %v", err)
}
db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open postgres connection: %v", err)
}
if err := db.AutoMigrate(&domain.Tenant{}); err != nil {
t.Fatalf("failed to migrate tenants: %v", err)
}
return db
}
func setSeedTenantCSVForDeleteGuard(t *testing.T, slug string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"Protected,COMPANY_GROUP,," + slug + ",Protected seed,\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
t.Setenv("SEED_TENANT_CSV_PATH", path)
}
func TestTenantHandlerDeleteTenantRejectsSeedTenant(t *testing.T) {
setSeedTenantCSVForDeleteGuard(t, "protected-root")
db := newTenantHandlerSeedDeleteDB(t)
tenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000001",
Name: "Protected",
Slug: "protected-root",
Type: domain.TenantTypeCompanyGroup,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
app := fiber.New()
app.Delete("/tenants/:id", (&TenantHandler{DB: db}).DeleteTenant)
req := httptest.NewRequest(http.MethodDelete, "/tenants/"+tenant.ID, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusConflict)
}
var count int64
if err := db.Model(&domain.Tenant{}).Where("id = ?", tenant.ID).Count(&count).Error; err != nil {
t.Fatalf("count tenant: %v", err)
}
if count != 1 {
t.Fatalf("seed tenant count = %d, want 1", count)
}
}
func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
setSeedTenantCSVForDeleteGuard(t, "protected-root")
db := newTenantHandlerSeedDeleteDB(t)
seed := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000011",
Name: "Protected",
Slug: "protected-root",
Type: domain.TenantTypeCompanyGroup,
Status: domain.TenantStatusActive,
}
normal := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000012",
Name: "Normal",
Slug: "normal",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&seed).Error; err != nil {
t.Fatalf("failed to create seed tenant: %v", err)
}
if err := db.Create(&normal).Error; err != nil {
t.Fatalf("failed to create normal tenant: %v", err)
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Delete("/tenants/bulk", (&TenantHandler{DB: db}).DeleteTenantsBulk)
body, _ := json.Marshal(map[string][]string{"ids": []string{seed.ID, normal.ID}})
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusConflict)
}
var count int64
if err := db.Model(&domain.Tenant{}).Where("id IN ?", []string{seed.ID, normal.ID}).Count(&count).Error; err != nil {
t.Fatalf("count tenants: %v", err)
}
if count != 2 {
t.Fatalf("remaining tenant count = %d, want 2", count)
}
}

View File

@@ -18,7 +18,7 @@ function LoginPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false); const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/chart"; const returnTo = searchParams.get("returnTo") || "/chart";
const shouldAutoLogin = searchParams.get("auto") !== "0"; const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => { useEffect(() => {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test"; import { type Page, expect, test } from "@playwright/test";
async function stubOidcAuthorization(page: Page) { async function stubOidcAuthorization(page: Page) {
let authorizationURL = ""; let authorizationURL = "";
@@ -35,20 +35,13 @@ async function stubOidcAuthorization(page: Page) {
}; };
} }
test("orgfront login defaults to OIDC authorization", async ({ page }) => { test("orgfront login waits for explicit auto parameter", async ({ page }) => {
const oidc = await stubOidcAuthorization(page); const oidc = await stubOidcAuthorization(page);
await page.goto("/login"); await page.goto("/login");
await page.waitForTimeout(500);
await expect.poll(oidc.authorizationURL).toContain("/oauth2/auth"); expect(oidc.authorizationURL()).toBe("");
const parsed = new URL(oidc.authorizationURL());
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
expect(parsed.searchParams.get("redirect_uri")).toBe(
"http://localhost:5175/auth/callback",
);
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("scope") ?? "").toContain("openid");
}); });
test("orgfront login auto parameter starts OIDC authorization", async ({ test("orgfront login auto parameter starts OIDC authorization", async ({