forked from baron/baron-sso
레포 업데이트
This commit is contained in:
@@ -28,6 +28,8 @@ DB_NAME=baron_sso
|
|||||||
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||||
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||||
JWT_SECRET=super-secret-key-must-be-32-bytes!
|
JWT_SECRET=super-secret-key-must-be-32-bytes!
|
||||||
|
# Optional backend slog override: debug, info, warn, error
|
||||||
|
BACKEND_LOG_LEVEL=
|
||||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ jobs:
|
|||||||
|
|
||||||
# .env 파일 생성
|
# .env 파일 생성
|
||||||
cat <<'EOF' > .env
|
cat <<'EOF' > .env
|
||||||
APP_ENV=${{ vars.APP_ENV }}
|
APP_ENV=stage
|
||||||
|
BACKEND_LOG_LEVEL=debug
|
||||||
|
CLIENT_LOG_DEBUG=true
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
IDP_PROVIDER=ory
|
IDP_PROVIDER=ory
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ jobs:
|
|||||||
|
|
||||||
# .env 파일 생성
|
# .env 파일 생성
|
||||||
cat <<'EOF' > .env
|
cat <<'EOF' > .env
|
||||||
APP_ENV=${{ vars.APP_ENV }}
|
APP_ENV=stage
|
||||||
|
BACKEND_LOG_LEVEL=debug
|
||||||
|
CLIENT_LOG_DEBUG=true
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
IDP_PROVIDER=ory
|
IDP_PROVIDER=ory
|
||||||
|
|
||||||
@@ -191,4 +193,4 @@ jobs:
|
|||||||
echo 'Kratos Migrate Failed. Logs:'; \
|
echo 'Kratos Migrate Failed. Logs:'; \
|
||||||
docker logs baron-sso-staging-kratos-migrate-1; \
|
docker logs baron-sso-staging-kratos-migrate-1; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi"
|
fi"
|
||||||
|
|||||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
152
.serena/project.yml
Normal file
152
.serena/project.yml
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "baron-sso"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# java julia kotlin lua markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- typescript
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific options.
|
||||||
|
# Maps the language key to the options.
|
||||||
|
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||||
|
# No documentation on options means no options are available.
|
||||||
|
ls_specific_settings: {}
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
#
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
13
README.md
13
README.md
@@ -327,6 +327,11 @@ USERFRONT_URL=https://sso.example.com
|
|||||||
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
|
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
|
||||||
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
|
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
|
||||||
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
|
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
|
||||||
|
- `BACKEND_LOG_LEVEL`: Backend `slog` 레벨 override (선택)
|
||||||
|
- 허용 값: `debug`, `info`, `warn`, `error`
|
||||||
|
- 미설정 시 `APP_ENV` 기준으로 결정됩니다.
|
||||||
|
- `dev|local|development`: `debug`
|
||||||
|
- 그 외(`stage`, `production`, `prod` 등): `info`
|
||||||
- `USERFRONT_DEBUG_LOG`: UserFront 측 디버그 로그 fallback 플래그
|
- `USERFRONT_DEBUG_LOG`: UserFront 측 디버그 로그 fallback 플래그
|
||||||
- `CLIENT_LOG_DEBUG`가 없을 때만 UserFront에서 대체로 참조
|
- `CLIENT_LOG_DEBUG`가 없을 때만 UserFront에서 대체로 참조
|
||||||
|
|
||||||
@@ -341,6 +346,14 @@ USERFRONT_URL=https://sso.example.com
|
|||||||
- 문자열 패턴(`token=...`, `authorization:...`, JSON body 내 민감 key)도 마스킹
|
- 문자열 패턴(`token=...`, `authorization:...`, JSON body 내 민감 key)도 마스킹
|
||||||
- 상세 정책 문서: `docs/client-log-policy.md`
|
- 상세 정책 문서: `docs/client-log-policy.md`
|
||||||
|
|
||||||
|
### Backend 로그 정책 (중요)
|
||||||
|
- Backend 서버 로그는 `APP_ENV` 기준으로 기본 레벨이 정해집니다.
|
||||||
|
- `dev|local|development`: `debug`
|
||||||
|
- 그 외(`stage`, `production`, `prod` 등): `info`
|
||||||
|
- 운영/스테이징에서 장애 분석이 필요할 때만 `BACKEND_LOG_LEVEL=debug`를 일시적으로 설정하는 것을 권장합니다.
|
||||||
|
- headless login 같은 경로의 상세 진단 필드는 `debug` 레벨에서만 추가로 남습니다.
|
||||||
|
- 상세 정책 문서: `docs/backend-log-policy.md`
|
||||||
|
|
||||||
### `.env` 작성 후 권장 점검
|
### `.env` 작성 후 권장 점검
|
||||||
```bash
|
```bash
|
||||||
make validate-auth-config
|
make validate-auth-config
|
||||||
|
|||||||
@@ -16,10 +16,5 @@ COPY . .
|
|||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||||
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
|
RUN chmod +x ./scripts/runtime-mode.sh
|
||||||
echo 'Running in production mode...'; \
|
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||||
npm run build && serve -s dist -l 5173; \
|
|
||||||
else \
|
|
||||||
echo 'Running in development mode...'; \
|
|
||||||
npm run dev -- --host 0.0.0.0; \
|
|
||||||
fi"
|
|
||||||
|
|||||||
26
adminfront/scripts/runtime-mode.sh
Normal file
26
adminfront/scripts/runtime-mode.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
case "$app_env" in
|
||||||
|
production|prod|stage|staging)
|
||||||
|
mode="production"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
mode="development"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--print-mode" ]; then
|
||||||
|
printf '%s\n' "$mode"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "production" ]; then
|
||||||
|
echo "Running in production mode..."
|
||||||
|
exec sh -c "npm run build && serve -s dist -l 5173"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running in development mode..."
|
||||||
|
exec npm run dev -- --host 0.0.0.0
|
||||||
@@ -116,3 +116,24 @@ func TestNewErrorHandler_MapsUnauthorizedCode(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error code: %v", body["code"])
|
t.Fatalf("unexpected error code: %v", body["code"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldEnableDocs_DisabledInProductionLikeEnv(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
appEnv string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{appEnv: "production", want: false},
|
||||||
|
{appEnv: "prod", want: false},
|
||||||
|
{appEnv: "stage", want: false},
|
||||||
|
{appEnv: "staging", want: false},
|
||||||
|
{appEnv: "dev", want: true},
|
||||||
|
{appEnv: "development", want: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
got := shouldEnableDocs(tc.appEnv)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("appEnv=%s expected shouldEnableDocs=%v, got %v", tc.appEnv, tc.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
419
backend/cmd/server/headless_login_e2e_test.go
Normal file
419
backend/cmd/server/headless_login_e2e_test.go
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
authhandler "baron-sso-backend/internal/handler"
|
||||||
|
"baron-sso-backend/internal/middleware"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type e2eMockIdentityProvider struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) Name() string {
|
||||||
|
return "mock-idp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||||
|
args := m.Called(loginID, password)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*domain.AuthInfo), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) UserExists(loginID string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) InitiatePasswordReset(loginID, redirectURL string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type e2eMockKratosAdminService struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||||
|
args := m.Called(ctx, identifier)
|
||||||
|
return args.String(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockKratosAdminService) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*service.KratosIdentity), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockKratosAdminService) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockKratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App {
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
DisableStartupMessage: true,
|
||||||
|
ErrorHandler: newErrorHandler(appEnv),
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Use(requestid.New(requestid.Config{
|
||||||
|
Generator: func() string {
|
||||||
|
return "req-e2e-headless"
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.Use(func(c *fiber.Ctx) error {
|
||||||
|
start := time.Now()
|
||||||
|
err := c.Next()
|
||||||
|
|
||||||
|
status := c.Response().StatusCode()
|
||||||
|
if status < 400 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "http_request"
|
||||||
|
if err != nil {
|
||||||
|
msg = "http_request_error"
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info(msg,
|
||||||
|
"status", status,
|
||||||
|
"method", c.Method(),
|
||||||
|
"path", c.Path(),
|
||||||
|
"latency", time.Since(start).String(),
|
||||||
|
"ip", c.IP(),
|
||||||
|
"req_id", c.GetRespHeader(fiber.HeaderXRequestID),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
|
||||||
|
app.Use(middleware.ErrorCodeEnricher())
|
||||||
|
|
||||||
|
api := app.Group("/api/v1")
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
auth.Post("/headless/password/login", h.HeadlessPasswordLogin)
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustE2EHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate rsa key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keySet := jose.JSONWebKeySet{
|
||||||
|
Keys: []jose.JSONWebKey{
|
||||||
|
{
|
||||||
|
Key: &privateKey.PublicKey,
|
||||||
|
KeyID: "test-kid",
|
||||||
|
Use: "sig",
|
||||||
|
Algorithm: string(jose.RS256),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(keySet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal jwks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jwks map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &jwks); err != nil {
|
||||||
|
t.Fatalf("failed to decode jwks map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateKey, jwks
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustE2EHeadlessClientAssertion(t *testing.T, privateKey *rsa.PrivateKey, clientID, audience string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
signer, err := jose.NewSigner(jose.SigningKey{
|
||||||
|
Algorithm: jose.RS256,
|
||||||
|
Key: jose.JSONWebKey{
|
||||||
|
Key: privateKey,
|
||||||
|
KeyID: "test-kid",
|
||||||
|
Use: "sig",
|
||||||
|
Algorithm: string(jose.RS256),
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create signer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
raw, err := josejwt.Signed(signer).Claims(josejwt.Claims{
|
||||||
|
Issuer: clientID,
|
||||||
|
Subject: clientID,
|
||||||
|
Audience: josejwt.Audience{audience},
|
||||||
|
Expiry: josejwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||||
|
IssuedAt: josejwt.NewNumericDate(now),
|
||||||
|
NotBefore: josejwt.NewNumericDate(now.Add(-1 * time.Minute)),
|
||||||
|
ID: "assertion-e2e",
|
||||||
|
}).Serialize()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to sign client assertion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockHydraTransportForE2E(handler http.Handler) http.RoundTripper {
|
||||||
|
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
return w.Result(), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHeadlessPasswordLoginE2E(
|
||||||
|
t *testing.T,
|
||||||
|
logger *slog.Logger,
|
||||||
|
appEnv string,
|
||||||
|
jwks map[string]any,
|
||||||
|
clientAssertion string,
|
||||||
|
) (*http.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
logBuffer := &bytes.Buffer{}
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := slog.Default()
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
slog.SetDefault(previous)
|
||||||
|
})
|
||||||
|
|
||||||
|
mockIDP := new(e2eMockIdentityProvider)
|
||||||
|
mockIDP.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||||
|
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||||
|
Subject: "kratos-identity-id",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
mockKratos := new(e2eMockKratosAdminService)
|
||||||
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||||
|
|
||||||
|
jwksBody, err := json.Marshal(jwks)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(jwksBody)
|
||||||
|
}))
|
||||||
|
t.Cleanup(jwksServer.Close)
|
||||||
|
|
||||||
|
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||||
|
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
|
Challenge: "challenge-123",
|
||||||
|
Client: domain.HydraClient{
|
||||||
|
ClientID: "headless-login-client",
|
||||||
|
TokenEndpointAuthMethod: "none",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"status": "active",
|
||||||
|
"headless_login_enabled": true,
|
||||||
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &authhandler.AuthHandler{
|
||||||
|
IdpProvider: mockIDP,
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: mockHydraTransportForE2E(hydraHandler)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app := newHeadlessLoginE2EApp(h, appEnv)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"client_id": "headless-login-client",
|
||||||
|
"client_assertion": clientAssertion,
|
||||||
|
"loginId": "employee001",
|
||||||
|
"password": "password",
|
||||||
|
"login_challenge": "challenge-123",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, logBuffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs(t *testing.T) {
|
||||||
|
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||||
|
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||||
|
t,
|
||||||
|
privateKey,
|
||||||
|
"headless-login-client",
|
||||||
|
"https://rp.example.com/oidc/token",
|
||||||
|
)
|
||||||
|
|
||||||
|
logBuffer := &bytes.Buffer{}
|
||||||
|
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
|
resp, _ := runHeadlessPasswordLoginE2E(t, logger, "production", jwks, clientAssertion)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 401, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("failed to decode response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["code"] != "invalid_client_assertion_audience" {
|
||||||
|
t.Fatalf("expected detailed code, got=%v", got["code"])
|
||||||
|
}
|
||||||
|
if got["error"] != "Client assertion audience mismatch" {
|
||||||
|
t.Fatalf("expected detailed error message, got=%v", got["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
output := logBuffer.String()
|
||||||
|
if !strings.Contains(output, "\"reason_code\":\"invalid_client_assertion_audience\"") {
|
||||||
|
t.Fatalf("expected headless failure log to include detailed reason code, got=%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "\"req_id\":\"req-e2e-headless\"") {
|
||||||
|
t.Fatalf("expected logs to include request id, got=%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "\"path\":\"/api/v1/auth/headless/password/login\"") {
|
||||||
|
t.Fatalf("expected request path in logs, got=%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics(t *testing.T) {
|
||||||
|
privateKey, jwks := mustE2EHeadlessRSAJWK(t)
|
||||||
|
const receivedAudience = "https://sso.hmac.kr/api/v1/auth/headless/password/login"
|
||||||
|
clientAssertion := mustE2EHeadlessClientAssertion(
|
||||||
|
t,
|
||||||
|
privateKey,
|
||||||
|
"headless-login-client",
|
||||||
|
receivedAudience,
|
||||||
|
)
|
||||||
|
|
||||||
|
logBuffer := &bytes.Buffer{}
|
||||||
|
logger := slog.New(slog.NewJSONHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
|
||||||
|
resp, _ := runHeadlessPasswordLoginE2E(t, logger, "production", jwks, clientAssertion)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 401, got %d, body=%s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
output := logBuffer.String()
|
||||||
|
if !strings.Contains(output, "\"expected_audiences\"") {
|
||||||
|
t.Fatalf("expected debug logs to include expected_audiences, got=%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "\"received_audiences\"") {
|
||||||
|
t.Fatalf("expected debug logs to include received_audiences, got=%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "\"received_audiences_text\":\""+receivedAudience+"\"") {
|
||||||
|
t.Fatalf("expected debug logs to include received_audiences_text with full URL, got=%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "\"expected_audiences_text\":\"http://example.com/api/v1/auth/headless/password/login, /api/v1/auth/headless/password/login\"") {
|
||||||
|
t.Fatalf("expected debug logs to include expected_audiences_text, got=%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "\"login_challenge_prefix\":\"challenge-12\"") {
|
||||||
|
t.Fatalf("expected debug logs to include login challenge prefix, got=%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,10 @@ func normalizeDocsPrefix(prefix string) string {
|
|||||||
return strings.TrimRight(trimmed, "/")
|
return strings.TrimRight(trimmed, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldEnableDocs(appEnv string) bool {
|
||||||
|
return !logger.IsProductionLikeEnv(appEnv)
|
||||||
|
}
|
||||||
|
|
||||||
func registerDocsRoutes(app *fiber.App, prefix string) {
|
func registerDocsRoutes(app *fiber.App, prefix string) {
|
||||||
base := normalizeDocsPrefix(prefix)
|
base := normalizeDocsPrefix(prefix)
|
||||||
docsPath := base + "/docs"
|
docsPath := base + "/docs"
|
||||||
@@ -90,9 +94,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 0. Initialize Logger
|
// 0. Initialize Logger
|
||||||
|
appEnvForLogger := getEnv("APP_ENV", getEnv("GO_ENV", "dev"))
|
||||||
logger.Init(logger.Config{
|
logger.Init(logger.Config{
|
||||||
ServiceName: "baron-sso",
|
ServiceName: "baron-sso",
|
||||||
Environment: getEnv("GO_ENV", "dev"),
|
Environment: appEnvForLogger,
|
||||||
|
LevelOverride: getEnv("BACKEND_LOG_LEVEL", ""),
|
||||||
})
|
})
|
||||||
// Initialize Snowflake Node (Node 2 for Baron)
|
// Initialize Snowflake Node (Node 2 for Baron)
|
||||||
node, err := snowflake.NewNode(2)
|
node, err := snowflake.NewNode(2)
|
||||||
@@ -407,7 +413,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// [Security] Disable Swagger/ReDoc in Production
|
// [Security] Disable Swagger/ReDoc in Production
|
||||||
if appEnv != "production" {
|
if shouldEnableDocs(appEnv) {
|
||||||
docsPrefix := getEnv("DOCS_BASE_PATH", "/api")
|
docsPrefix := getEnv("DOCS_BASE_PATH", "/api")
|
||||||
registerDocsRoutes(app, "")
|
registerDocsRoutes(app, "")
|
||||||
if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" {
|
if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" {
|
||||||
@@ -415,7 +421,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix)
|
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("🔒 API Docs disabled in production")
|
slog.Info("🔒 API Docs disabled in production-like environment", "app_env", appEnv)
|
||||||
}
|
}
|
||||||
slog.Info("Client log policy configured",
|
slog.Info("Client log policy configured",
|
||||||
"app_env", appEnv,
|
"app_env", appEnv,
|
||||||
|
|||||||
@@ -1770,6 +1770,21 @@ func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bo
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func joinHeadlessAudiences(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
trimmed := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trimmed = append(trimmed, value)
|
||||||
|
}
|
||||||
|
return strings.Join(trimmed, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
func headlessRequestID(c *fiber.Ctx) string {
|
func headlessRequestID(c *fiber.Ctx) string {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -1894,14 +1909,18 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
|
|||||||
|
|
||||||
func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) *headlessLoginFailure {
|
func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) *headlessLoginFailure {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
expectedAudiences := headlessAssertionAudiences(c)
|
||||||
|
receivedAudiences := []string(claims.Audience)
|
||||||
debugFields := map[string]any{
|
debugFields := map[string]any{
|
||||||
"claim_issuer": claims.Issuer,
|
"claim_issuer": claims.Issuer,
|
||||||
"claim_subject": claims.Subject,
|
"claim_subject": claims.Subject,
|
||||||
"claim_expires_at": claims.ExpiresAt,
|
"claim_expires_at": claims.ExpiresAt,
|
||||||
"claim_not_before": claims.NotBefore,
|
"claim_not_before": claims.NotBefore,
|
||||||
"claim_issued_at": claims.IssuedAt,
|
"claim_issued_at": claims.IssuedAt,
|
||||||
"received_audiences": []string(claims.Audience),
|
"received_audiences": receivedAudiences,
|
||||||
"expected_audiences": headlessAssertionAudiences(c),
|
"expected_audiences": expectedAudiences,
|
||||||
|
"received_audiences_text": joinHeadlessAudiences(receivedAudiences),
|
||||||
|
"expected_audiences_text": joinHeadlessAudiences(expectedAudiences),
|
||||||
}
|
}
|
||||||
if claims.Issuer != clientID || claims.Subject != clientID {
|
if claims.Issuer != clientID || claims.Subject != clientID {
|
||||||
return newHeadlessLoginFailure(
|
return newHeadlessLoginFailure(
|
||||||
@@ -1939,7 +1958,7 @@ func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAs
|
|||||||
debugFields,
|
debugFields,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if !containsHeadlessAudience(headlessAssertionAudiences(c), claims.Audience) {
|
if !containsHeadlessAudience(expectedAudiences, claims.Audience) {
|
||||||
return newHeadlessLoginFailure(
|
return newHeadlessLoginFailure(
|
||||||
fiber.StatusUnauthorized,
|
fiber.StatusUnauthorized,
|
||||||
"invalid_client_assertion_audience",
|
"invalid_client_assertion_audience",
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func IsProductionEnv(appEnv string) bool {
|
func IsProductionEnv(appEnv string) bool {
|
||||||
env := strings.ToLower(strings.TrimSpace(appEnv))
|
return IsProductionLikeEnv(appEnv)
|
||||||
return env == "prod" || env == "production"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBoolFlag(raw string) bool {
|
func parseBoolFlag(raw string) bool {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ func TestClientDebugEnabled(t *testing.T) {
|
|||||||
t.Run("production disables debug by default", func(t *testing.T) {
|
t.Run("production disables debug by default", func(t *testing.T) {
|
||||||
assert.False(t, ClientDebugEnabled("production", ""))
|
assert.False(t, ClientDebugEnabled("production", ""))
|
||||||
assert.False(t, ClientDebugEnabled("prod", "false"))
|
assert.False(t, ClientDebugEnabled("prod", "false"))
|
||||||
|
assert.False(t, ClientDebugEnabled("stage", ""))
|
||||||
|
assert.False(t, ClientDebugEnabled("staging", "false"))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("production accepts explicit debug override", func(t *testing.T) {
|
t.Run("production accepts explicit debug override", func(t *testing.T) {
|
||||||
@@ -27,14 +29,19 @@ func TestClientDebugEnabled(t *testing.T) {
|
|||||||
func TestShouldAcceptClientLog(t *testing.T) {
|
func TestShouldAcceptClientLog(t *testing.T) {
|
||||||
assert.False(t, ShouldAcceptClientLog("production", "", "INFO"))
|
assert.False(t, ShouldAcceptClientLog("production", "", "INFO"))
|
||||||
assert.False(t, ShouldAcceptClientLog("production", "", "DEBUG"))
|
assert.False(t, ShouldAcceptClientLog("production", "", "DEBUG"))
|
||||||
|
assert.False(t, ShouldAcceptClientLog("stage", "", "INFO"))
|
||||||
|
assert.False(t, ShouldAcceptClientLog("stage", "", "DEBUG"))
|
||||||
assert.True(t, ShouldAcceptClientLog("production", "", "WARN"))
|
assert.True(t, ShouldAcceptClientLog("production", "", "WARN"))
|
||||||
assert.True(t, ShouldAcceptClientLog("production", "", "ERROR"))
|
assert.True(t, ShouldAcceptClientLog("production", "", "ERROR"))
|
||||||
|
assert.True(t, ShouldAcceptClientLog("stage", "", "WARN"))
|
||||||
|
assert.True(t, ShouldAcceptClientLog("stage", "", "ERROR"))
|
||||||
assert.True(t, ShouldAcceptClientLog("production", "true", "INFO"))
|
assert.True(t, ShouldAcceptClientLog("production", "true", "INFO"))
|
||||||
assert.True(t, ShouldAcceptClientLog("dev", "", "INFO"))
|
assert.True(t, ShouldAcceptClientLog("dev", "", "INFO"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldFilterNoisyClientInfo(t *testing.T) {
|
func TestShouldFilterNoisyClientInfo(t *testing.T) {
|
||||||
assert.True(t, ShouldFilterNoisyClientInfo("production", "", "Navigating to /ko/signin"))
|
assert.True(t, ShouldFilterNoisyClientInfo("production", "", "Navigating to /ko/signin"))
|
||||||
|
assert.True(t, ShouldFilterNoisyClientInfo("stage", "", "Navigating to /ko/signin"))
|
||||||
assert.False(t, ShouldFilterNoisyClientInfo("production", "true", "Navigating to /ko/signin"))
|
assert.False(t, ShouldFilterNoisyClientInfo("production", "true", "Navigating to /ko/signin"))
|
||||||
assert.False(t, ShouldFilterNoisyClientInfo("dev", "", "Navigating to /ko/signin"))
|
assert.False(t, ShouldFilterNoisyClientInfo("dev", "", "Navigating to /ko/signin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -8,18 +9,28 @@ import (
|
|||||||
|
|
||||||
// Config holds the logger configuration
|
// Config holds the logger configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
Environment string // "dev", "local", "production"
|
Environment string // APP_ENV 기준
|
||||||
|
LevelOverride string
|
||||||
|
Output io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsProductionLikeEnv(appEnv string) bool {
|
||||||
|
env := strings.ToLower(strings.TrimSpace(appEnv))
|
||||||
|
return env == "prod" || env == "production" || env == "stage" || env == "staging"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the global logger with slog.
|
// Init initializes the global logger with slog.
|
||||||
// It detects the environment to switch between TextHandler (dev) and JSONHandler (prod).
|
// It detects the environment to switch between TextHandler (dev) and JSONHandler (prod).
|
||||||
func Init(cfg Config) {
|
func Init(cfg Config) {
|
||||||
var handler slog.Handler
|
var handler slog.Handler
|
||||||
|
output := cfg.Output
|
||||||
|
if output == nil {
|
||||||
|
output = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
opts := &slog.HandlerOptions{
|
opts := &slog.HandlerOptions{
|
||||||
// Default level
|
Level: ResolveBackendLogLevel(cfg.Environment, cfg.LevelOverride),
|
||||||
Level: slog.LevelInfo,
|
|
||||||
// Customize attributes (Time format)
|
// Customize attributes (Time format)
|
||||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||||
if a.Key == slog.TimeKey {
|
if a.Key == slog.TimeKey {
|
||||||
@@ -32,11 +43,10 @@ func Init(cfg Config) {
|
|||||||
// Adjust level and format based on environment
|
// Adjust level and format based on environment
|
||||||
env := strings.ToLower(cfg.Environment)
|
env := strings.ToLower(cfg.Environment)
|
||||||
if env == "dev" || env == "local" || env == "development" {
|
if env == "dev" || env == "local" || env == "development" {
|
||||||
opts.Level = slog.LevelDebug
|
handler = slog.NewTextHandler(output, opts)
|
||||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
|
||||||
} else {
|
} else {
|
||||||
// Production defaults to JSON
|
// Production defaults to JSON
|
||||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
handler = slog.NewJSONHandler(output, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create logger with common attributes
|
// Create logger with common attributes
|
||||||
@@ -47,3 +57,22 @@ func Init(cfg Config) {
|
|||||||
// Set as global default logger
|
// Set as global default logger
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ResolveBackendLogLevel(appEnv, override string) slog.Level {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(override)) {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "info":
|
||||||
|
return slog.LevelInfo
|
||||||
|
case "warn", "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
}
|
||||||
|
|
||||||
|
env := strings.ToLower(strings.TrimSpace(appEnv))
|
||||||
|
if env == "dev" || env == "local" || env == "development" {
|
||||||
|
return slog.LevelDebug
|
||||||
|
}
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
|||||||
69
backend/internal/logger/logger_test.go
Normal file
69
backend/internal/logger/logger_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveBackendLogLevel_DefaultsByAppEnv(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
appEnv string
|
||||||
|
wantLevel slog.Level
|
||||||
|
}{
|
||||||
|
{name: "dev uses debug", appEnv: "dev", wantLevel: slog.LevelDebug},
|
||||||
|
{name: "local uses debug", appEnv: "local", wantLevel: slog.LevelDebug},
|
||||||
|
{name: "development uses debug", appEnv: "development", wantLevel: slog.LevelDebug},
|
||||||
|
{name: "stage uses info", appEnv: "stage", wantLevel: slog.LevelInfo},
|
||||||
|
{name: "production uses info", appEnv: "production", wantLevel: slog.LevelInfo},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ResolveBackendLogLevel(tc.appEnv, "")
|
||||||
|
if got != tc.wantLevel {
|
||||||
|
t.Fatalf("expected level %v, got %v", tc.wantLevel, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBackendLogLevel_OverrideWins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ResolveBackendLogLevel("production", "debug")
|
||||||
|
if got != slog.LevelDebug {
|
||||||
|
t.Fatalf("expected debug override, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = ResolveBackendLogLevel("dev", "warn")
|
||||||
|
if got != slog.LevelWarn {
|
||||||
|
t.Fatalf("expected warn override, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_UsesResolvedBackendLogLevel(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
previous := slog.Default()
|
||||||
|
defer slog.SetDefault(previous)
|
||||||
|
|
||||||
|
Init(Config{
|
||||||
|
ServiceName: "baron-sso",
|
||||||
|
Environment: "stage",
|
||||||
|
LevelOverride: "debug",
|
||||||
|
Output: &buf,
|
||||||
|
})
|
||||||
|
|
||||||
|
slog.Debug("debug message should be visible")
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), "debug message should be visible") {
|
||||||
|
t.Fatalf("expected debug log to be written, got=%s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/logger"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -10,7 +11,7 @@ func IsProductionEnv() bool {
|
|||||||
if env == "" {
|
if env == "" {
|
||||||
env = strings.ToLower(os.Getenv("GO_ENV"))
|
env = strings.ToLower(os.Getenv("GO_ENV"))
|
||||||
}
|
}
|
||||||
return env == "prod" || env == "production"
|
return logger.IsProductionLikeEnv(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsDryRunAllowed() bool {
|
func IsDryRunAllowed() bool {
|
||||||
|
|||||||
43
backend/internal/service/dry_run_service_test.go
Normal file
43
backend/internal/service/dry_run_service_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsProductionEnv_StageIsProductionLike(t *testing.T) {
|
||||||
|
t.Setenv("APP_ENV", "stage")
|
||||||
|
t.Setenv("GO_ENV", "")
|
||||||
|
|
||||||
|
if !IsProductionEnv() {
|
||||||
|
t.Fatalf("expected stage to be treated as production-like")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDryRunAllowed_DisabledInStage(t *testing.T) {
|
||||||
|
t.Setenv("APP_ENV", "stage")
|
||||||
|
t.Setenv("GO_ENV", "")
|
||||||
|
|
||||||
|
if IsDryRunAllowed() {
|
||||||
|
t.Fatalf("expected dry-run to be disabled in stage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsProductionEnv_FallsBackToGoEnv(t *testing.T) {
|
||||||
|
originalAppEnv, hadAppEnv := os.LookupEnv("APP_ENV")
|
||||||
|
if hadAppEnv {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Setenv("APP_ENV", originalAppEnv)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Unsetenv("APP_ENV")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = os.Unsetenv("APP_ENV")
|
||||||
|
t.Setenv("GO_ENV", "production")
|
||||||
|
|
||||||
|
if !IsProductionEnv() {
|
||||||
|
t.Fatalf("expected GO_ENV=production fallback to be production-like")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,5 @@ COPY . .
|
|||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||||
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
|
RUN chmod +x ./scripts/runtime-mode.sh
|
||||||
echo 'Running in production mode...'; \
|
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||||
npm run build && serve -s dist -l 5173; \
|
|
||||||
else \
|
|
||||||
echo 'Running in development mode...'; \
|
|
||||||
npm run dev -- --host 0.0.0.0; \
|
|
||||||
fi"
|
|
||||||
|
|||||||
26
devfront/scripts/runtime-mode.sh
Normal file
26
devfront/scripts/runtime-mode.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
case "$app_env" in
|
||||||
|
production|prod|stage|staging)
|
||||||
|
mode="production"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
mode="development"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--print-mode" ]; then
|
||||||
|
printf '%s\n' "$mode"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "production" ]; then
|
||||||
|
echo "Running in production mode..."
|
||||||
|
exec sh -c "npm run build && serve -s dist -l 5173"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running in development mode..."
|
||||||
|
exec npm run dev -- --host 0.0.0.0
|
||||||
74
docs/backend-log-policy.md
Normal file
74
docs/backend-log-policy.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Backend Log Policy
|
||||||
|
|
||||||
|
## 1. 목적
|
||||||
|
- Backend 서버 로그의 기본 레벨과 운영 중 일시적 디버그 확장 규칙을 정의합니다.
|
||||||
|
- 운영 환경에서 과도한 로그 노출을 피하면서, 장애 분석이 필요한 경우에는 명시적으로 진단 로그를 확장할 수 있게 합니다.
|
||||||
|
|
||||||
|
## 2. 기준 변수
|
||||||
|
- `APP_ENV`
|
||||||
|
- `BACKEND_LOG_LEVEL` (선택 override)
|
||||||
|
|
||||||
|
## 3. 기본 동작
|
||||||
|
|
||||||
|
### 3.1 기본 레벨 결정
|
||||||
|
- `APP_ENV=dev|local|development`
|
||||||
|
- 기본 로그 레벨: `debug`
|
||||||
|
- 기본 출력 형식: text
|
||||||
|
- 그 외 환경(`stage`, `production`, `prod` 등)
|
||||||
|
- 기본 로그 레벨: `info`
|
||||||
|
- 기본 출력 형식: JSON
|
||||||
|
|
||||||
|
### 3.2 명시 override
|
||||||
|
- `BACKEND_LOG_LEVEL`이 설정되면 `APP_ENV` 기본값보다 우선합니다.
|
||||||
|
- 허용 값:
|
||||||
|
- `debug`
|
||||||
|
- `info`
|
||||||
|
- `warn`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
예시:
|
||||||
|
```env
|
||||||
|
APP_ENV=stage
|
||||||
|
BACKEND_LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
위 설정이면 stage 환경이더라도 backend `slog`는 `debug`로 동작합니다.
|
||||||
|
|
||||||
|
## 4. 운영 가이드
|
||||||
|
- 운영/스테이징에서 장애 분석이 필요할 때만 `BACKEND_LOG_LEVEL=debug`를 일시적으로 설정합니다.
|
||||||
|
- 이슈 분석이 끝나면 즉시 기본값으로 되돌리는 것을 권장합니다.
|
||||||
|
- 기본 레벨(`info`)에서는 핵심 상태 변화와 경고/오류를 중심으로 남기고, 디버그 전용 진단 필드는 숨깁니다.
|
||||||
|
|
||||||
|
## 5. Headless Login 진단 로그
|
||||||
|
- `POST /api/v1/auth/headless/password/login` 같은 headless 로그인 경로는 기본적으로 `reason_code` 중심의 구조화 로그를 남깁니다.
|
||||||
|
- `BACKEND_LOG_LEVEL=debug` 또는 `APP_ENV=dev|local|development`일 때만 아래 진단 필드를 추가로 남깁니다.
|
||||||
|
- `expected_audiences`
|
||||||
|
- `received_audiences`
|
||||||
|
- `received_kid`
|
||||||
|
- `claim_issuer`
|
||||||
|
- `claim_subject`
|
||||||
|
- `claim_expires_at`
|
||||||
|
- `claim_not_before`
|
||||||
|
- `claim_issued_at`
|
||||||
|
- 민감 정보는 계속 로그에 남기지 않습니다.
|
||||||
|
- raw `client_assertion`
|
||||||
|
- password
|
||||||
|
- session token / cookie
|
||||||
|
|
||||||
|
## 6. 구현 위치
|
||||||
|
- logger 초기화: `backend/cmd/server/main.go`
|
||||||
|
- 레벨 결정 로직: `backend/internal/logger/logger.go`
|
||||||
|
- headless 로그인 debug 진단 필드: `backend/internal/handler/auth_handler.go`
|
||||||
|
|
||||||
|
## 7. 검증
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test ./internal/logger -v
|
||||||
|
go test ./cmd/server -run 'TestNewErrorHandler_' -v
|
||||||
|
go test ./internal/handler -run 'TestHeadlessPasswordLogin_(DebugLogIncludesDiagnostics|InfoLogOmitsDebugDiagnostics)$' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 관련 문서
|
||||||
|
- `README.md`
|
||||||
|
- `docs/client-log-policy.md`
|
||||||
|
- wiki update draft: `docs/wiki-error-handling-policy-backend-log-update.md`
|
||||||
@@ -65,3 +65,7 @@ flutter test test/log_policy_test.dart
|
|||||||
## 6. 운영 가이드
|
## 6. 운영 가이드
|
||||||
- 운영에서 디버그 로그가 필요하면 `CLIENT_LOG_DEBUG=true`를 명시적으로 설정하고, 이슈 해결 후 즉시 원복합니다.
|
- 운영에서 디버그 로그가 필요하면 `CLIENT_LOG_DEBUG=true`를 명시적으로 설정하고, 이슈 해결 후 즉시 원복합니다.
|
||||||
- 운영에서도 민감정보 마스킹은 항상 강제되며 비활성화할 수 없습니다.
|
- 운영에서도 민감정보 마스킹은 항상 강제되며 비활성화할 수 없습니다.
|
||||||
|
|
||||||
|
## 7. 참고
|
||||||
|
- Backend 서버 자체의 `slog` 레벨 정책은 별도로 관리합니다.
|
||||||
|
- 관련 문서: `docs/backend-log-policy.md`
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Headless Login Debug Improvement Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make headless password login failures return safe reason-specific response codes and produce debug-only diagnostic logs.
|
||||||
|
|
||||||
|
**Architecture:** Keep the change local to `AuthHandler` by introducing a headless-password-specific error classification helper and reusing it across response mapping and structured logging. Preserve current success-path behavior while adding explicit tests for reason codes and debug-field gating.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, Fiber, `log/slog`, `stretchr/testify`, `go-jose`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add failing response-classification tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/handler/auth_handler_login_test.go`
|
||||||
|
- Reference: `backend/internal/handler/auth_handler.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for classified 401 responses**
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
|
||||||
|
- audience mismatch -> `code=invalid_client_assertion_audience`
|
||||||
|
- signature mismatch -> `code=invalid_client_assertion_signature`
|
||||||
|
- iss/sub mismatch -> `code=invalid_client_assertion_iss_sub`
|
||||||
|
- expired assertion -> `code=invalid_client_assertion_expired`
|
||||||
|
- invalid credentials -> `code=password_or_email_mismatch`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run only the new response tests and verify they fail for the expected reason**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./internal/handler -run 'TestHeadlessPasswordLogin_(AudienceMismatchReturnsDetailedCode|SignatureMismatchReturnsDetailedCode|IssSubMismatchReturnsDetailedCode|ExpiredAssertionReturnsDetailedCode)|TestPasswordLogin_InvalidCredentials_ReturnsCode' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- new headless response tests fail because the handler still returns generic codes/messages
|
||||||
|
- existing invalid-credentials test remains green unless intentionally updated
|
||||||
|
|
||||||
|
### Task 2: Add failing log-gating tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/handler/auth_handler_login_test.go`
|
||||||
|
- Reference: `backend/internal/logger/logger.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests for debug-only diagnostic logging**
|
||||||
|
|
||||||
|
Add tests that install a temporary `slog` handler backed by a buffer and assert:
|
||||||
|
|
||||||
|
- debug logger emits `expected_audiences` and `received_audiences`
|
||||||
|
- info logger does not emit those debug-only fields
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run only the new log tests and verify they fail**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./internal/handler -run 'TestHeadlessPasswordLogin_(DebugLogIncludesDiagnostics|InfoLogOmitsDebugDiagnostics)' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- tests fail because the current handler does not emit reason-specific structured logs yet
|
||||||
|
|
||||||
|
### Task 3: Implement classified headless assertion failures
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/handler/auth_handler.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a local classified error type and helpers**
|
||||||
|
|
||||||
|
Implement a small helper shape for:
|
||||||
|
|
||||||
|
- `status`
|
||||||
|
- `code`
|
||||||
|
- `safeMessage`
|
||||||
|
- `logMessage`
|
||||||
|
- `debugAttrs`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Make claim validation return classified reasons**
|
||||||
|
|
||||||
|
Replace generic string errors from `validateHeadlessClientAssertionClaims` with typed/classified failures for:
|
||||||
|
|
||||||
|
- iss/sub mismatch
|
||||||
|
- expired
|
||||||
|
- not active yet
|
||||||
|
- iat in future
|
||||||
|
- audience mismatch
|
||||||
|
|
||||||
|
- [ ] **Step 3: Make assertion verification return signature/jwks-specific failures**
|
||||||
|
|
||||||
|
Ensure parse, jwks load, claims, and signature-verification paths map to distinct safe codes/messages.
|
||||||
|
|
||||||
|
### Task 4: Implement structured logging and handler integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/handler/auth_handler.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a helper to detect whether debug logging is enabled**
|
||||||
|
|
||||||
|
Use the default `slog` logger handler to gate diagnostic fields without changing global logger initialization.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Log classified assertion failures in `HeadlessPasswordLogin`**
|
||||||
|
|
||||||
|
Log:
|
||||||
|
|
||||||
|
- reason code
|
||||||
|
- client id
|
||||||
|
- path
|
||||||
|
- debug-only attrs when debug logging is enabled
|
||||||
|
|
||||||
|
- [ ] **Step 3: Log classified credential failures**
|
||||||
|
|
||||||
|
Keep response behavior safe while adding a structured log line for credential mismatch.
|
||||||
|
|
||||||
|
### Task 5: Verify and document
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/test-plan/backend-test-inventory.md` if headless password login inventory needs expansion
|
||||||
|
- Modify: issue `#502` comment with failing-test evidence and implementation summary
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused handler tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./internal/handler -run 'TestHeadlessPasswordLogin_|TestPasswordLogin_InvalidCredentials_ReturnsCode' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- all targeted tests pass
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run broader backend handler regression tests if practical**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./internal/handler -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- no regressions in auth handler tests
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update issue `#502` with what failed first, what changed, and what passed**
|
||||||
|
|
||||||
|
- [ ] **Step 4: Review whether `docs/test-plan/backend-test-inventory.md` or troubleshooting docs should be updated**
|
||||||
146
docs/superpowers/specs/2026-04-01-headless-login-debug-design.md
Normal file
146
docs/superpowers/specs/2026-04-01-headless-login-debug-design.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Headless Login Debug and Error Classification Design
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This design covers issue `#502` only.
|
||||||
|
|
||||||
|
- Target endpoint: `POST /api/v1/auth/headless/password/login`
|
||||||
|
- Goal: classify 401 failure reasons for headless password login, return a safe response to RP clients, and emit richer diagnostics only when debug logging is enabled
|
||||||
|
- Non-goal: introduce a shared auth-wide error/observability layer across all handlers. That follow-up work is tracked separately in issue `#503`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Current behavior makes production debugging slow.
|
||||||
|
|
||||||
|
- Multiple assertion failures collapse into `invalid_client_assertion`
|
||||||
|
- The handler often returns early without emitting a reason-specific log line
|
||||||
|
- RP clients cannot distinguish safe failure categories such as audience mismatch vs signature mismatch
|
||||||
|
- Server operators cannot quickly tell whether the failure came from client assertion validation or user credential validation
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Response policy must follow the agreed level `2`: return `code + short safe message`
|
||||||
|
- Debug logs may include level `3`-style diagnostics, but only when the logger is configured for debug level
|
||||||
|
- Sensitive values must never be logged: raw `client_assertion`, password, session token, cookies
|
||||||
|
- Existing successful headless password login flow must remain unchanged
|
||||||
|
- Existing policy requires failing tests first
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
Introduce a small headless-password-specific failure classification layer inside `auth_handler.go`.
|
||||||
|
|
||||||
|
The classification layer should map each failure into:
|
||||||
|
|
||||||
|
- `status`
|
||||||
|
- `code`
|
||||||
|
- `safeMessage`
|
||||||
|
- `logMessage`
|
||||||
|
- `debugFields`
|
||||||
|
|
||||||
|
This stays local to the current handler so the change remains small, but the structure should be reusable enough to extract later in issue `#503`.
|
||||||
|
|
||||||
|
## Failure Categories
|
||||||
|
|
||||||
|
### Client assertion failures
|
||||||
|
|
||||||
|
- `invalid_client_assertion_parse`
|
||||||
|
- `invalid_client_assertion_signature`
|
||||||
|
- `invalid_client_assertion_iss_sub`
|
||||||
|
- `invalid_client_assertion_expired`
|
||||||
|
- `invalid_client_assertion_not_before`
|
||||||
|
- `invalid_client_assertion_iat_future`
|
||||||
|
- `invalid_client_assertion_audience`
|
||||||
|
- `invalid_client_assertion_jwks_load`
|
||||||
|
|
||||||
|
### Credential failure
|
||||||
|
|
||||||
|
- `password_or_email_mismatch`
|
||||||
|
|
||||||
|
## Response Contract
|
||||||
|
|
||||||
|
Responses should expose only safe details.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `{"code":"invalid_client_assertion_audience","error":"Client assertion audience mismatch"}`
|
||||||
|
- `{"code":"invalid_client_assertion_signature","error":"Client assertion signature verification failed"}`
|
||||||
|
- `{"code":"password_or_email_mismatch","error":"Invalid credentials"}`
|
||||||
|
|
||||||
|
The response must not echo:
|
||||||
|
|
||||||
|
- expected vs received audience values
|
||||||
|
- full claim payload
|
||||||
|
- kid mismatch internals beyond the safe message
|
||||||
|
- raw token values
|
||||||
|
|
||||||
|
## Logging Strategy
|
||||||
|
|
||||||
|
### Normal log level
|
||||||
|
|
||||||
|
Emit one structured warning/error log per classified failure with:
|
||||||
|
|
||||||
|
- `reason_code`
|
||||||
|
- `client_id`
|
||||||
|
- `path`
|
||||||
|
- `req_id` if present in context/log pipeline
|
||||||
|
|
||||||
|
Message examples:
|
||||||
|
|
||||||
|
- `headless password login client assertion failed`
|
||||||
|
- `headless password login credential authentication failed`
|
||||||
|
|
||||||
|
### Debug log level
|
||||||
|
|
||||||
|
Add diagnostic fields that are useful for root cause analysis:
|
||||||
|
|
||||||
|
- `expected_audiences`
|
||||||
|
- `received_audiences`
|
||||||
|
- `received_kid`
|
||||||
|
- `jwks_refreshed`
|
||||||
|
- `claim_issuer`
|
||||||
|
- `claim_subject`
|
||||||
|
- `claim_expires_at`
|
||||||
|
- `claim_not_before`
|
||||||
|
- `claim_issued_at`
|
||||||
|
- `login_challenge_prefix`
|
||||||
|
|
||||||
|
The `login_challenge` should be truncated before logging.
|
||||||
|
|
||||||
|
## Implementation Shape
|
||||||
|
|
||||||
|
Inside `backend/internal/handler/auth_handler.go`:
|
||||||
|
|
||||||
|
1. Add a small internal type for classified headless assertion errors.
|
||||||
|
2. Make `validateHeadlessClientAssertionClaims` return structured reasons instead of generic strings.
|
||||||
|
3. Make `verifyHeadlessClientAssertion` return a classified failure that the handler can both:
|
||||||
|
- convert to a safe HTTP response
|
||||||
|
- log with optional debug fields
|
||||||
|
4. Add a small helper that checks whether debug logging is enabled for the current default `slog` logger.
|
||||||
|
5. Log credential authentication failures with their reason code as well.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add failing tests first in `backend/internal/handler/auth_handler_login_test.go`.
|
||||||
|
|
||||||
|
Required tests:
|
||||||
|
|
||||||
|
- audience mismatch returns `401 + invalid_client_assertion_audience`
|
||||||
|
- signature mismatch returns `401 + invalid_client_assertion_signature`
|
||||||
|
- iss/sub mismatch returns `401 + invalid_client_assertion_iss_sub`
|
||||||
|
- expired assertion returns `401 + invalid_client_assertion_expired`
|
||||||
|
- invalid credentials still returns `401 + password_or_email_mismatch`
|
||||||
|
- debug logger captures diagnostic fields for assertion audience mismatch
|
||||||
|
- non-debug logger does not emit debug-only fields
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Over-classifying too much detail into RP responses could create unnecessary information disclosure
|
||||||
|
- Logging helper implementation must not assume a specific handler type beyond standard `slog`
|
||||||
|
- Existing tests may assume the older generic `invalid_client_assertion` code and will need intentional updates
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- Operators can distinguish signature, claims, and credential failures from logs
|
||||||
|
- RP clients get safe but actionable reason codes
|
||||||
|
- Debug mode includes additional diagnostics without leaking secrets
|
||||||
|
- Existing success path and previously covered headless login behavior remain green
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
| `backend/cmd/server/error_handler_test.go:48` | `TestNewErrorHandler_ProductionPassesClientError` | 오류/예외/거부 경로 검증 |
|
| `backend/cmd/server/error_handler_test.go:48` | `TestNewErrorHandler_ProductionPassesClientError` | 오류/예외/거부 경로 검증 |
|
||||||
| `backend/cmd/server/error_handler_test.go:73` | `TestNewErrorHandler_DevelopmentReturnsOriginalServerError` | 오류/예외/거부 경로 검증 |
|
| `backend/cmd/server/error_handler_test.go:73` | `TestNewErrorHandler_DevelopmentReturnsOriginalServerError` | 오류/예외/거부 경로 검증 |
|
||||||
| `backend/cmd/server/error_handler_test.go:98` | `TestNewErrorHandler_MapsUnauthorizedCode` | 오류/예외/거부 경로 검증 |
|
| `backend/cmd/server/error_handler_test.go:98` | `TestNewErrorHandler_MapsUnauthorizedCode` | 오류/예외/거부 경로 검증 |
|
||||||
|
| `backend/cmd/server/headless_login_e2e_test.go:338` | `TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs` | 실제 app 경로에서 headless login 401 응답 본문과 구조화 로그를 함께 검증 |
|
||||||
|
| `backend/cmd/server/headless_login_e2e_test.go:382` | `TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics` | 실제 app 경로에서 debug 레벨일 때만 headless 진단 필드가 로그에 포함되는지 검증 |
|
||||||
| `backend/internal/handler/api_key_handler_test.go:19` | `TestApiKeyHandler_CreateApiKey` | 핵심 CRUD/서비스 동작 검증 |
|
| `backend/internal/handler/api_key_handler_test.go:19` | `TestApiKeyHandler_CreateApiKey` | 핵심 CRUD/서비스 동작 검증 |
|
||||||
| `backend/internal/handler/api_key_handler_test.go:41` | `TestApiKeyHandler_Validation` | 유효성/정책/유틸 검증 |
|
| `backend/internal/handler/api_key_handler_test.go:41` | `TestApiKeyHandler_Validation` | 유효성/정책/유틸 검증 |
|
||||||
| `backend/internal/handler/auth_handler_async_test.go:198` | `TestSignup_AsyncDB_Isolation` | 복구/격리/회복 탄력성 검증 |
|
| `backend/internal/handler/auth_handler_async_test.go:198` | `TestSignup_AsyncDB_Isolation` | 복구/격리/회복 탄력성 검증 |
|
||||||
@@ -23,6 +25,13 @@
|
|||||||
| `backend/internal/handler/auth_handler_login_test.go:114` | `TestPasswordLogin_OIDC_Success` | 인증/OIDC 플로우 검증 |
|
| `backend/internal/handler/auth_handler_login_test.go:114` | `TestPasswordLogin_OIDC_Success` | 인증/OIDC 플로우 검증 |
|
||||||
| `backend/internal/handler/auth_handler_login_test.go:201` | `TestPasswordLogin_OIDC_InactiveClient` | 오류/예외/거부 경로 검증 |
|
| `backend/internal/handler/auth_handler_login_test.go:201` | `TestPasswordLogin_OIDC_InactiveClient` | 오류/예외/거부 경로 검증 |
|
||||||
| `backend/internal/handler/auth_handler_login_test.go:255` | `TestPasswordLogin_NoOIDC_Success` | 인증/OIDC 플로우 검증 |
|
| `backend/internal/handler/auth_handler_login_test.go:255` | `TestPasswordLogin_NoOIDC_Success` | 인증/OIDC 플로우 검증 |
|
||||||
|
| `backend/internal/handler/auth_handler_login_test.go:940` | `TestHeadlessPasswordLogin_InvalidClientAssertionRejected` | headless 로그인 서명 검증 실패 응답 코드 검증 |
|
||||||
|
| `backend/internal/handler/auth_handler_login_test.go:1033` | `TestHeadlessPasswordLogin_AudienceMismatchReturnsDetailedCode` | headless 로그인 audience mismatch 분류 검증 |
|
||||||
|
| `backend/internal/handler/auth_handler_login_test.go:1062` | `TestHeadlessPasswordLogin_IssSubMismatchReturnsDetailedCode` | headless 로그인 iss/sub mismatch 분류 검증 |
|
||||||
|
| `backend/internal/handler/auth_handler_login_test.go:1102` | `TestHeadlessPasswordLogin_ExpiredAssertionReturnsDetailedCode` | headless 로그인 만료 assertion 분류 검증 |
|
||||||
|
| `backend/internal/handler/auth_handler_login_test.go:1142` | `TestHeadlessPasswordLogin_DebugLogIncludesDiagnostics` | debug 로그에서만 진단 필드가 노출되는지 검증 |
|
||||||
|
| `backend/internal/handler/auth_handler_login_test.go:1174` | `TestHeadlessPasswordLogin_InfoLogOmitsDebugDiagnostics` | 일반 로그에서 debug 진단 필드가 숨겨지는지 검증 |
|
||||||
|
| `backend/internal/handler/auth_handler_login_test.go:1442` | `TestPasswordLogin_InvalidCredentials_ReturnsCode` | 비밀번호 불일치 응답 코드 검증 |
|
||||||
| `backend/internal/handler/auth_handler_oidc_test.go:106` | `TestAcceptOidcLoginRequest_TokenFallbackToCookie` | 복구/격리/회복 탄력성 검증 |
|
| `backend/internal/handler/auth_handler_oidc_test.go:106` | `TestAcceptOidcLoginRequest_TokenFallbackToCookie` | 복구/격리/회복 탄력성 검증 |
|
||||||
| `backend/internal/handler/auth_handler_oidc_test.go:21` | `TestAcceptOidcLoginRequest_CookieOnly` | 인증/OIDC 플로우 검증 |
|
| `backend/internal/handler/auth_handler_oidc_test.go:21` | `TestAcceptOidcLoginRequest_CookieOnly` | 인증/OIDC 플로우 검증 |
|
||||||
| `backend/internal/handler/auth_handler_otp_test.go:14` | `TestHandleKratosCourierRelay_Email` | 인증/OIDC 플로우 검증 |
|
| `backend/internal/handler/auth_handler_otp_test.go:14` | `TestHandleKratosCourierRelay_Email` | 인증/OIDC 플로우 검증 |
|
||||||
|
|||||||
307
docs/wiki-error-handling-policy-backend-log-update.md
Normal file
307
docs/wiki-error-handling-policy-backend-log-update.md
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
# Wiki Update Draft: Error-Handling-Policy
|
||||||
|
|
||||||
|
대상 위키 페이지:
|
||||||
|
- `Error-Handling-Policy`
|
||||||
|
|
||||||
|
이 문서는 위키에 바로 반영할 수 있도록, 기존 `Error Handling Policy` 원문 구조를 유지하면서 최근 backend 로그 정책과 headless login 디버그 규칙까지 함께 정리한 초안입니다.
|
||||||
|
|
||||||
|
반영 의도:
|
||||||
|
- 기존 whitelist 중심의 prod 에러 노출 정책을 유지합니다.
|
||||||
|
- 최근 추가된 headless login 세분화 오류 코드와 backend debug log 규칙을 같은 문맥에서 관리합니다.
|
||||||
|
- UI 노출 정책과 backend API 응답 계약을 분리해 해석합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Error Handling Policy
|
||||||
|
|
||||||
|
본 문서는 이슈 `#164`([UserFront] 에러 노출 whitelist 정의 및 적용)를 기준으로 정리한 **프로덕션 에러 노출 정책**입니다.
|
||||||
|
|
||||||
|
## 0) 범위와 해석 기준
|
||||||
|
- 본 정책은 `userfront`, `adminfront`, `devfront`, `backend`가 공통으로 참고하는 에러 노출 기준입니다.
|
||||||
|
- `Backend`는 기계 판독 가능한 `code`와 안전한 짧은 `error` 문자열을 내려주는 책임을 가집니다.
|
||||||
|
- `Front`는 `code`를 기준으로 사용자 문구를 번역/매핑해 표시합니다.
|
||||||
|
- 따라서 아래 표에서 한국어 문구는 **최종 사용자 노출 기준**, 영문 `error` 문자열은 **backend 응답 예시**로 해석합니다.
|
||||||
|
|
||||||
|
## 1) 기본 원칙
|
||||||
|
- 프로덕션은 **whitelist 방식**만 허용합니다.
|
||||||
|
- whitelist에 없는 에러는 **일반 오류 메시지**로 대체합니다.
|
||||||
|
- 상세 원문 메시지는 **프로덕션에서 비노출**합니다.
|
||||||
|
- **Ory Stack(Kratos/Hydra/Oathkeeper)에서 발생한 에러 코드는 그대로 pass-through** 합니다.
|
||||||
|
- **Custom 에러만 whitelist로 관리**합니다. (Ory 코드 제외)
|
||||||
|
|
||||||
|
## 2) 노출 정책 및 Ory 에러 처리 (통합)
|
||||||
|
|
||||||
|
### 2.1 Ory 에러 pass-through 원칙
|
||||||
|
- **Ory Stack(Kratos/Hydra/Oathkeeper)에서 발생한 에러 코드는 그대로 pass-through** 합니다.
|
||||||
|
- Ory 에러는 **whitelist 대상에서 제외**합니다.
|
||||||
|
- 단, 보안/UX 관점의 **blacklist 후보**는 예외적으로 `unknown_error`로 치환할 수 있습니다.
|
||||||
|
|
||||||
|
### 2.2 Custom whitelist (Ory 매핑 없음)
|
||||||
|
Ory 스택에서 발생하지 않으며 **매핑 대상이 없는 Custom 에러**만 관리합니다.
|
||||||
|
이 중 **HTTP 상태 코드와 일치하는 항목**은 별도 표로 구분합니다.
|
||||||
|
|
||||||
|
#### 2.2.1 HTTP status와 일치하는 Custom 에러
|
||||||
|
| error_code | http_status | 사용자 메시지 (기본) | Backend `error` 예시 | 설명 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `not_found` | 404 | 요청한 페이지를 찾을 수 없습니다. | Not found | 경로 오류 |
|
||||||
|
| `rate_limited` | 429 | 요청이 많습니다. 잠시 후 다시 시도해 주세요. | Too many requests | 제한 초과 |
|
||||||
|
|
||||||
|
#### 2.2.2 HTTP status와 무관한 Custom 에러
|
||||||
|
| error_code | 사용자 메시지 (기본) | Backend `error` 예시 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| `password_or_email_mismatch` | 이메일 혹은 비밀번호가 일치하지 않습니다. | Invalid credentials | 비밀번호 입력 오류 |
|
||||||
|
| `invalid_client_assertion_parse` | 클라이언트 인증 정보 형식이 올바르지 않습니다. | Client assertion format is invalid | headless login assertion 형식 오류 |
|
||||||
|
| `invalid_client_assertion_signature` | 클라이언트 인증 정보 검증에 실패했습니다. | Client assertion signature verification failed | headless login assertion 서명 검증 실패 |
|
||||||
|
| `invalid_client_assertion_iss_sub` | 클라이언트 인증 정보 발급 주체가 올바르지 않습니다. | Client assertion issuer or subject mismatch | headless login assertion `iss/sub` 불일치 |
|
||||||
|
| `invalid_client_assertion_expired` | 클라이언트 인증 정보가 만료되었습니다. | Client assertion has expired | headless login assertion 만료 |
|
||||||
|
| `invalid_client_assertion_not_before` | 클라이언트 인증 정보가 아직 활성 상태가 아닙니다. | Client assertion is not active yet | headless login assertion 활성 시각 전 |
|
||||||
|
| `invalid_client_assertion_iat_future` | 클라이언트 인증 정보 발급 시각이 올바르지 않습니다. | Client assertion issued-at time is invalid | headless login assertion `iat` 미래 시각 |
|
||||||
|
| `invalid_client_assertion_audience` | 클라이언트 인증 정보 대상이 일치하지 않습니다. | Client assertion audience mismatch | headless login assertion `aud` 불일치 |
|
||||||
|
| `invalid_client_assertion_jwks_load` | 클라이언트 공개키 검증에 실패했습니다. | Headless login jwks verification failed | headless login `jwksUri` 조회/파싱 실패 계열 |
|
||||||
|
|
||||||
|
### 2.3 HTTP status 핸들링 정책
|
||||||
|
- **Ory error 코드가 존재하면 pass-through 우선**합니다.
|
||||||
|
- Ory error 코드가 없고, **HTTP status가 404/429**인 경우:
|
||||||
|
- `404` -> `not_found`
|
||||||
|
- `429` -> `rate_limited`
|
||||||
|
- 위 조건에 해당하지 않는 Custom 에러는 **기본 정책(`unknown_error`)**을 적용합니다.
|
||||||
|
|
||||||
|
### 2.4 비노출(기본) 에러 처리
|
||||||
|
whitelist에 없는 모든 **Custom 에러**는 아래 공통 처리 규칙을 따릅니다.
|
||||||
|
- 사용자 메시지: **"일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."**
|
||||||
|
- 오류 종류는 `unknown_error`로 고정합니다.
|
||||||
|
- 상세 원문 메시지는 사용자에게 표시하지 않습니다.
|
||||||
|
|
||||||
|
### 2.5 Ory 에러 blacklist 후보 (검토용)
|
||||||
|
Ory 에러를 pass-through 하더라도, 아래 유형은 **보안/UX 관점에서 숨김 처리(blacklist)** 후보입니다.
|
||||||
|
- `security_csrf_violation`
|
||||||
|
- `security_identity_mismatch`
|
||||||
|
- `browser_location_change_required`
|
||||||
|
- `server_error`
|
||||||
|
- `temporarily_unavailable`
|
||||||
|
|
||||||
|
> **제안**: 위 코드는 prod에서 `unknown_error`로 치환하고, log/audit에만 원문을 남기는 방식이 안전합니다.
|
||||||
|
|
||||||
|
### 2.6 Oathkeeper 경유 에러 처리
|
||||||
|
현재 설정(`docker/ory/oathkeeper/oathkeeper.yml`)에는 에러 변환 로직이 없고, `errors.fallback: json`만 정의되어 있습니다.
|
||||||
|
즉, Oathkeeper는 **에러 코드를 변환하지 않고 JSON으로 그대로 반환**합니다.
|
||||||
|
따라서 Ory Stack 에러는 **Oathkeeper를 통과하더라도 그대로 유지**된다고 가정합니다.
|
||||||
|
|
||||||
|
## 3) UI 정책
|
||||||
|
- 공통 에러 화면에는 아래 항목을 표시합니다.
|
||||||
|
- 제목
|
||||||
|
- 사용자 메시지
|
||||||
|
- **오류 종류(`error_code`)**
|
||||||
|
- **홈으로 이동 버튼**
|
||||||
|
- `error_id`가 있는 경우에만 표시합니다.
|
||||||
|
- Backend의 `error` 문자열은 최종 사용자 문구의 Source of Truth가 아닙니다.
|
||||||
|
- Front는 가능하면 `code` 기준으로 번역 리소스를 선택하고, `error`는 fallback 또는 운영 진단 보조 텍스트로만 사용합니다.
|
||||||
|
|
||||||
|
## 4) 구현 가이드
|
||||||
|
- 에러 표시 로직은 **whitelist 검사 후** 결정합니다.
|
||||||
|
- 예시:
|
||||||
|
- `if error_code in whitelist: message = whitelist_message`
|
||||||
|
- `else: error_code = "unknown_error", message = default_message`
|
||||||
|
|
||||||
|
## 5) 프로덕션 전용 동작 및 테스트 요구사항
|
||||||
|
|
||||||
|
### 5.1 프로덕션 전용 동작
|
||||||
|
- whitelist 적용(비노출/`unknown_error` 치환)은 **프로덕션에서만** 동작해야 합니다.
|
||||||
|
- **Ory Stack 에러는 prod에서도 pass-through** 합니다. (단, blacklist 후보는 예외 처리 가능)
|
||||||
|
- 프로덕션 판정 기준:
|
||||||
|
- Front: `APP_ENV`가 `prod` 또는 `production`일 때만 활성화
|
||||||
|
- 테스트/로컬: override 옵션을 사용해 프로덕션/비프로덕션 동작을 강제할 수 있어야 합니다.
|
||||||
|
|
||||||
|
### 5.2 테스트 요구사항
|
||||||
|
최소 아래 케이스를 자동 테스트로 보장합니다.
|
||||||
|
- **Prod + whitelist 코드**: 사용자 메시지는 whitelist 메시지, `error_code`는 원래 코드 유지
|
||||||
|
- **Prod + 비-whitelist 코드**: 사용자 메시지는 기본 메시지, `error_code`는 `unknown_error`
|
||||||
|
- **Prod + `error_id` 없음**: `error_id` 표시 없음
|
||||||
|
- **Non-prod + `error_code` 존재**: 원본 에러 코드/설명 표시
|
||||||
|
- **Non-prod + `description` 없음**: 기본 설명 노출
|
||||||
|
|
||||||
|
권장 테스트 위치:
|
||||||
|
- `userfront/test/error_screen_test.dart`
|
||||||
|
|
||||||
|
권장 테스트 방식:
|
||||||
|
- `ErrorScreen(isProdOverride: true/false, ...)`로 환경을 강제하여 동작 검증
|
||||||
|
- `AuthProxyService.isProdEnv`가 `APP_ENV`에 의존하므로, 테스트에서 직접 환경 변수에 의존하지 않도록 override 사용
|
||||||
|
|
||||||
|
### 5.3 재현 테스트 우선 원칙
|
||||||
|
- 에러 처리 로직 변경 시, 먼저 재현 테스트를 작성합니다.
|
||||||
|
- 테스트는 최소한 `status`, `code`, `error` 응답 계약을 검증해야 합니다.
|
||||||
|
- 사용자 노출 메시지는 번역 리소스로 처리하고, API는 기계 판독 가능한 `code`를 우선 계약으로 유지합니다.
|
||||||
|
- 원문(한글/영문) 에러 문자열이 바뀌어도 `code` 기반 동작은 깨지지 않아야 합니다.
|
||||||
|
- 운영 이슈에서 확보한 `req_id`, 로그 패턴, 실제 응답 payload는 가능하면 테스트 케이스 설명에 남겨 회귀 근거를 보존합니다.
|
||||||
|
|
||||||
|
### 5.4 회귀 테스트 기준
|
||||||
|
- 인증 실패(예: password mismatch)에서 `code`가 기대값으로 반환되는지 검증합니다.
|
||||||
|
- 4xx/5xx 주요 에러 경로에 대해 최소 1개 이상의 핸들러 테스트를 유지합니다.
|
||||||
|
- 운영 이슈로 확인된 에러 케이스는 반드시 회귀 테스트 케이스로 승격합니다.
|
||||||
|
|
||||||
|
### 5.5 Headless Login 실패 코드 회귀 기준
|
||||||
|
Headless login 경로는 기존 generic `invalid_client_assertion`만으로는 운영 진단이 느렸기 때문에, 아래 코드를 별도 회귀 대상으로 유지합니다.
|
||||||
|
- `invalid_client_assertion_parse`
|
||||||
|
- `invalid_client_assertion_signature`
|
||||||
|
- `invalid_client_assertion_iss_sub`
|
||||||
|
- `invalid_client_assertion_expired`
|
||||||
|
- `invalid_client_assertion_not_before`
|
||||||
|
- `invalid_client_assertion_iat_future`
|
||||||
|
- `invalid_client_assertion_audience`
|
||||||
|
- `invalid_client_assertion_jwks_load`
|
||||||
|
- `password_or_email_mismatch`
|
||||||
|
|
||||||
|
권장 테스트 위치:
|
||||||
|
- `backend/internal/handler/auth_handler_login_test.go`
|
||||||
|
|
||||||
|
최소 검증 항목:
|
||||||
|
- 응답 `status`
|
||||||
|
- 응답 `code`
|
||||||
|
- 응답 `error`
|
||||||
|
- debug 레벨에서만 진단 필드가 로그에 포함되는지 여부
|
||||||
|
|
||||||
|
## 6) 변경 관리
|
||||||
|
- 에러 코드 추가/삭제는 **이슈 등록 후** 반영합니다.
|
||||||
|
- 사용자 메시지는 제품 문구 기준에 따라 수정합니다.
|
||||||
|
|
||||||
|
## 7) Ory 에러 코드(참고)
|
||||||
|
아래는 Ory(Kratos/Hydra)에서 **기본 제공되는 에러 코드**를 참고용으로 정리합니다.
|
||||||
|
|
||||||
|
### 7.1 Ory Kratos `error.id`
|
||||||
|
Kratos Self-Service Flow의 `error.id`는 다음 코드들이 공식 문서/SDK에 명시되어 있습니다.
|
||||||
|
- `session_inactive`
|
||||||
|
- `session_already_available`
|
||||||
|
- `session_aal1_required`
|
||||||
|
- `session_refresh_required`
|
||||||
|
- `security_csrf_violation`
|
||||||
|
- `security_identity_mismatch`
|
||||||
|
- `browser_location_change_required`
|
||||||
|
|
||||||
|
### 7.2 Ory Hydra / OAuth2·OIDC 표준 에러
|
||||||
|
Hydra는 OAuth2/OIDC 표준 에러 코드(`error` 필드)를 사용합니다.
|
||||||
|
- OAuth2 표준:
|
||||||
|
- `invalid_request`
|
||||||
|
- `unauthorized_client`
|
||||||
|
- `access_denied`
|
||||||
|
- `unsupported_response_type`
|
||||||
|
- `invalid_scope`
|
||||||
|
- `server_error`
|
||||||
|
- `temporarily_unavailable`
|
||||||
|
- OIDC 표준:
|
||||||
|
- `consent_required`
|
||||||
|
|
||||||
|
## 8) 에러 코드 관리 위치 제안 (아키텍처 기준)
|
||||||
|
에러 코드는 **Backend에서 표준화하고, Front에서 사용자 문구로 매핑**하는 구조가 가장 안전합니다.
|
||||||
|
|
||||||
|
### 8.1 Backend (단일 진입점, 표준화의 Source of Truth)
|
||||||
|
- 외부(Ory Kratos/Hydra/Oathkeeper) 및 내부 에러를 **표준 `error_code`로 변환**하는 로직을 Backend에 둡니다.
|
||||||
|
- 권장 위치:
|
||||||
|
- `backend/internal/handler/` 또는 `backend/internal/service/` 하위에 `error_mapper.go` 성격의 모듈
|
||||||
|
- 예시: `backend/internal/service/error_mapper.go`
|
||||||
|
- Backend 응답은 다음을 보장합니다.
|
||||||
|
- `error_code`와 `error_id`를 일관 포맷으로 내려줌
|
||||||
|
- whitelist 외 코드는 `unknown_error`로 치환 (프로덕션 기준)
|
||||||
|
|
||||||
|
### 8.2 Front (문구 매핑, 표현 계층)
|
||||||
|
- 사용자 메시지와 UI 표시는 Front에서 담당합니다.
|
||||||
|
- 현재 위치(유지 또는 통합 권장):
|
||||||
|
- `userfront/lib/core/constants/error_whitelist.dart`
|
||||||
|
- `userfront/lib/features/auth/presentation/error_screen.dart`
|
||||||
|
- Admin/DevFront에서도 같은 whitelist를 사용해야 하므로, 아래 중 하나로 통일을 권장합니다.
|
||||||
|
1. Backend에서 error whitelist 리스트를 내려주는 API 제공
|
||||||
|
2. 공용 패키지/공용 파일로 관리 후 각 Front에서 참조
|
||||||
|
|
||||||
|
### 8.3 Ory Stack / Gateway
|
||||||
|
- Ory(Kratos/Hydra)와 Oathkeeper는 **원문 에러만 발생시키고 표준화는 하지 않음**을 원칙으로 합니다.
|
||||||
|
- Gateway/Proxy 레이어는 **에러 코드를 변환하지 않음**이 안전합니다.
|
||||||
|
|
||||||
|
## 9) Backend Log Level Policy
|
||||||
|
에러 응답 정책과 운영 디버깅 정책은 분리하되, 실제 운영에서 함께 보게 되는 경우가 많으므로 backend 로그 레벨 규칙을 같이 관리합니다.
|
||||||
|
|
||||||
|
### 9.1 기준 변수
|
||||||
|
- `APP_ENV`
|
||||||
|
- `BACKEND_LOG_LEVEL` (optional override)
|
||||||
|
|
||||||
|
### 9.2 기본 규칙
|
||||||
|
- `APP_ENV=dev|local|development`
|
||||||
|
- backend `slog` 기본 레벨은 `debug`
|
||||||
|
- text handler 사용
|
||||||
|
- 그 외 환경(`stage`, `production`, `prod` 등)
|
||||||
|
- backend `slog` 기본 레벨은 `info`
|
||||||
|
- JSON handler 사용
|
||||||
|
|
||||||
|
### 9.3 운영 override
|
||||||
|
- 운영/스테이징에서 장애 분석이 필요한 경우에만 `BACKEND_LOG_LEVEL=debug`를 일시적으로 설정합니다.
|
||||||
|
- 허용 값:
|
||||||
|
- `debug`
|
||||||
|
- `info`
|
||||||
|
- `warn`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
예시:
|
||||||
|
```env
|
||||||
|
APP_ENV=stage
|
||||||
|
BACKEND_LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 Headless Login 디버그 필드
|
||||||
|
- headless login 경로는 기본적으로 `reason_code` 중심으로 실패 원인을 기록합니다.
|
||||||
|
- `debug` 레벨일 때만 추가 진단 필드를 남깁니다.
|
||||||
|
- `expected_audiences`
|
||||||
|
- `received_audiences`
|
||||||
|
- `received_kid`
|
||||||
|
- `claim_issuer`
|
||||||
|
- `claim_subject`
|
||||||
|
- `claim_expires_at`
|
||||||
|
- `claim_not_before`
|
||||||
|
- `claim_issued_at`
|
||||||
|
- `login_challenge_prefix`
|
||||||
|
|
||||||
|
### 9.5 응답과 로그의 역할 분리
|
||||||
|
- API 응답은 `2번 정책`에 따라 `code + 짧은 안전 메시지`까지만 포함합니다.
|
||||||
|
- 상세 실패 원인은 구조화 로그에서 확인합니다.
|
||||||
|
- 같은 실패라도 응답에는 축약된 정보만, debug 로그에는 운영 진단용 필드를 남기는 것이 기본 원칙입니다.
|
||||||
|
|
||||||
|
### 9.6 민감 정보 비노출 원칙
|
||||||
|
- 아래 값은 로그에 직접 남기지 않습니다.
|
||||||
|
- raw `client_assertion`
|
||||||
|
- password
|
||||||
|
- session token
|
||||||
|
- cookie
|
||||||
|
|
||||||
|
### 9.7 운영 메모
|
||||||
|
- 운영에서는 기본적으로 `info`를 유지합니다.
|
||||||
|
- 장애 분석이 끝나면 `BACKEND_LOG_LEVEL` override는 즉시 제거합니다.
|
||||||
|
- 클라이언트 로그 정책(`CLIENT_LOG_DEBUG`)과 backend logger 정책(`BACKEND_LOG_LEVEL`)은 별도입니다.
|
||||||
|
|
||||||
|
## 10) 부록: Ory UI Error Codes 처리 원칙 (요약)
|
||||||
|
Ory Kratos UI 문서의 원칙을 기반으로, Baron 정책에 반영해야 할 핵심 처리 방침을 요약합니다.
|
||||||
|
- 메시지는 **root / method / field** 레벨에 붙을 수 있으며, UI에서 범위를 고려해 표시해야 합니다.
|
||||||
|
- UI 메시지는 **`id`, `text`, `type`, `context`** 형태로 전달되며, `id`는 **고정된 값**입니다.
|
||||||
|
- 메시지 `id`는 **7자리 규칙(xyyzzzz)**을 따릅니다.
|
||||||
|
- `x`: 메시지 타입 (1=info, 4=input validation error, 5=generic error)
|
||||||
|
- `yy`: 모듈/플로우 (01=login, 02=logout, 03=MFA, 04=registration, 05=settings, 06=recovery, 07=verification)
|
||||||
|
- `zzzz`: 구체 메시지 ID
|
||||||
|
- SPA/Native UI에서는 Ory가 **에러 응답을 직접 반환**하는 경우가 있으므로, **UserFront에 에러 ID별 처리 로직**이 필요합니다. (예: flow 만료/재시작, 인증 단계 재진입 등)
|
||||||
|
- Ory는 React 레퍼런스 구현에서 에러 처리 로직 예시를 제공합니다.
|
||||||
|
- UI 메시지 목록은 **machine readable JSON**으로 제공합니다.
|
||||||
|
|
||||||
|
## 11) References
|
||||||
|
- Ory Kratos UI error codes: https://www.ory.com/docs/kratos/concepts/ui-user-interface#ui-error-codes
|
||||||
|
- Ory Kratos React error handling example: https://github.com/ory/kratos-react-nextjs-ui/blob/master/pkg/errors.tsx
|
||||||
|
- Ory Kratos UI messages (machine readable): https://github.com/ory/docs/blob/master/docs/kratos/concepts/messages.json
|
||||||
|
- Ory Kratos User-facing errors: https://www.ory.com/docs/kratos/self-service/flows/user-facing-errors
|
||||||
|
- Ory Kratos Advanced Integration (SPAs and 422 error, `browser_location_change_required`): https://www.ory.sh/docs/kratos/bring-your-own-ui/custom-ui-advanced-integration
|
||||||
|
- Ory Kratos API (Postman) - Create Login Flow for Native Apps (`session_already_available`, `session_aal1_required`, `security_csrf_violation`): https://www.postman.com/ory-docs/ory/request/uy54y0r/create-login-flow-for-native-apps
|
||||||
|
- Ory Kratos API (Postman) - Create Registration Flow for Native Apps (`session_already_available`, `security_csrf_violation`): https://www.postman.com/ory-docs/ory/request/5vmu1ui/create-registration-flow-for-native-apps
|
||||||
|
- Ory Kratos API (Postman) - Create Settings Flow for Browsers (`session_inactive`, `security_csrf_violation`, `security_identity_mismatch`): https://www.postman.com/ory-docs/ory/request/pyfglhb/create-settings-flow-for-browsers
|
||||||
|
- Ory Kratos Client Docs (`session_refresh_required` 등): https://docs.rs/crate/ory-client/latest/source/docs/FrontendApi.md
|
||||||
|
- RFC 6749 (OAuth2 error codes): https://www.rfc-editor.org/rfc/rfc6749.txt
|
||||||
|
- OpenID Connect Core 1.0 (`consent_required`): https://openid.net/specs/openid-connect-core-1_0-31.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 로컬 참조 문서
|
||||||
|
- `docs/backend-log-policy.md`
|
||||||
|
- `docs/client-log-policy.md`
|
||||||
|
- `docs/test-plan/backend-test-inventory.md`
|
||||||
27
scripts/test_frontend_runtime_mode.sh
Normal file
27
scripts/test_frontend_runtime_mode.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
assert_mode() {
|
||||||
|
script_path="$1"
|
||||||
|
app_env="$2"
|
||||||
|
expected="$3"
|
||||||
|
actual="$(APP_ENV="$app_env" sh "$script_path" --print-mode)"
|
||||||
|
if [ "$actual" != "$expected" ]; then
|
||||||
|
echo "script=$script_path APP_ENV=$app_env expected mode=$expected got=$actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for script in \
|
||||||
|
"./adminfront/scripts/runtime-mode.sh" \
|
||||||
|
"./devfront/scripts/runtime-mode.sh"
|
||||||
|
do
|
||||||
|
assert_mode "$script" "production" "production"
|
||||||
|
assert_mode "$script" "prod" "production"
|
||||||
|
assert_mode "$script" "stage" "production"
|
||||||
|
assert_mode "$script" "staging" "production"
|
||||||
|
assert_mode "$script" "development" "development"
|
||||||
|
assert_mode "$script" "dev" "development"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "frontend runtime mode checks passed"
|
||||||
22
scripts/test_staging_workflow_env.sh
Normal file
22
scripts/test_staging_workflow_env.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
file="$1"
|
||||||
|
pattern="$2"
|
||||||
|
if ! grep -Fq "$pattern" "$file"; then
|
||||||
|
echo "missing pattern in $file: $pattern" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for workflow in \
|
||||||
|
".gitea/workflows/staging_code_pull.yml" \
|
||||||
|
".gitea/workflows/staging_release.yml"
|
||||||
|
do
|
||||||
|
assert_contains "$workflow" "APP_ENV=stage"
|
||||||
|
assert_contains "$workflow" "BACKEND_LOG_LEVEL=debug"
|
||||||
|
assert_contains "$workflow" "CLIENT_LOG_DEBUG=true"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "staging workflow env checks passed"
|
||||||
@@ -36,7 +36,7 @@ class AuthProxyService {
|
|||||||
|
|
||||||
static bool get _isProd {
|
static bool get _isProd {
|
||||||
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
|
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
|
||||||
return env == 'prod' || env == 'production';
|
return LogPolicy.isProductionEnv(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get isProdEnv => _isProd;
|
static bool get isProdEnv => _isProd;
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ class LogPolicy {
|
|||||||
|
|
||||||
static bool isProductionEnv(String? appEnv) {
|
static bool isProductionEnv(String? appEnv) {
|
||||||
final env = (appEnv ?? '').trim().toLowerCase();
|
final env = (appEnv ?? '').trim().toLowerCase();
|
||||||
return env == 'prod' || env == 'production';
|
return env == 'prod' ||
|
||||||
|
env == 'production' ||
|
||||||
|
env == 'stage' ||
|
||||||
|
env == 'staging';
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool parseBoolFlag(String? raw) {
|
static bool parseBoolFlag(String? raw) {
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import 'package:userfront/core/services/log_policy.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('LogPolicy.debugEnabled', () {
|
group('LogPolicy.debugEnabled', () {
|
||||||
test('non production enables debug by default', () {
|
test('development-like environment enables debug by default', () {
|
||||||
expect(
|
expect(
|
||||||
LogPolicy.debugEnabled(appEnv: 'dev', productionDebugFlag: null),
|
LogPolicy.debugEnabled(appEnv: 'dev', productionDebugFlag: null),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
LogPolicy.debugEnabled(appEnv: 'staging', productionDebugFlag: 'false'),
|
LogPolicy.debugEnabled(
|
||||||
|
appEnv: 'development',
|
||||||
|
productionDebugFlag: 'false',
|
||||||
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -19,6 +22,10 @@ void main() {
|
|||||||
LogPolicy.debugEnabled(appEnv: 'production', productionDebugFlag: ''),
|
LogPolicy.debugEnabled(appEnv: 'production', productionDebugFlag: ''),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
LogPolicy.debugEnabled(appEnv: 'stage', productionDebugFlag: ''),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
LogPolicy.debugEnabled(
|
LogPolicy.debugEnabled(
|
||||||
appEnv: 'production',
|
appEnv: 'production',
|
||||||
@@ -43,6 +50,14 @@ void main() {
|
|||||||
),
|
),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
LogPolicy.shouldRelayClientLog(
|
||||||
|
level: 'INFO',
|
||||||
|
appEnv: 'stage',
|
||||||
|
productionDebugFlag: '',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
LogPolicy.shouldRelayClientLog(
|
LogPolicy.shouldRelayClientLog(
|
||||||
level: 'WARNING',
|
level: 'WARNING',
|
||||||
@@ -51,6 +66,14 @@ void main() {
|
|||||||
),
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
LogPolicy.shouldRelayClientLog(
|
||||||
|
level: 'WARNING',
|
||||||
|
appEnv: 'stage',
|
||||||
|
productionDebugFlag: '',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
LogPolicy.shouldRelayClientLog(
|
LogPolicy.shouldRelayClientLog(
|
||||||
level: 'ERROR',
|
level: 'ERROR',
|
||||||
|
|||||||
Reference in New Issue
Block a user