diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 20a8d7db..9a4e2d7d 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -26,6 +26,11 @@ on: required: true type: boolean default: true + run_userfront_coverage: + description: "Run userfront Flutter coverage and upload LCOV report" + required: true + type: boolean + default: true run_userfront_e2e_tests: description: "Run userfront WASM Playwright E2E tests" required: true @@ -74,6 +79,7 @@ jobs: biome: ${{ steps.filter.outputs.biome }} backend: ${{ steps.filter.outputs.backend }} userfront: ${{ steps.filter.outputs.userfront }} + userfront_coverage: ${{ steps.filter.outputs.userfront_coverage }} userfront_e2e: ${{ steps.filter.outputs.userfront_e2e }} front_coverage: ${{ steps.filter.outputs.front_coverage }} adminfront: ${{ steps.filter.outputs.adminfront }} @@ -95,7 +101,7 @@ jobs: } if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - for key in any lint biome backend userfront userfront_e2e front_coverage adminfront devfront orgfront; do + for key in any lint biome backend userfront userfront_coverage userfront_e2e front_coverage adminfront devfront orgfront; do set_output "$key" true done exit 0 @@ -129,6 +135,7 @@ jobs: backend=false userfront=false + userfront_coverage=false userfront_e2e=false adminfront=false devfront=false @@ -138,6 +145,7 @@ jobs: if matches "$global|^backend/"; then backend=true; fi if matches "$global|$i18n_shared|^userfront/"; then userfront=true; fi + if matches "$global|$i18n_shared|^userfront/|^scripts/summarize_flutter_coverage\.mjs"; then userfront_coverage=true; fi if matches "$global|$i18n_shared|^userfront/|^userfront-e2e/"; then userfront_e2e=true; fi if matches "$front_shared|^adminfront/"; then adminfront=true; fi if matches "$front_shared|^devfront/"; then devfront=true; fi @@ -151,7 +159,7 @@ jobs: fi any=false - for value in "$lint" "$biome" "$backend" "$userfront" "$userfront_e2e" "$front_coverage" "$adminfront" "$devfront" "$orgfront"; do + for value in "$lint" "$biome" "$backend" "$userfront" "$userfront_coverage" "$userfront_e2e" "$front_coverage" "$adminfront" "$devfront" "$orgfront"; do if [ "$value" = true ]; then any=true; fi done @@ -160,6 +168,7 @@ jobs: set_output biome "$biome" set_output backend "$backend" set_output userfront "$userfront" + set_output userfront_coverage "$userfront_coverage" set_output userfront_e2e "$userfront_e2e" set_output front_coverage "$front_coverage" set_output adminfront "$adminfront" @@ -352,21 +361,51 @@ jobs: mkdir -p reports set +e cd backend + go test -v -coverprofile=../reports/backend-coverage.out -covermode=atomic \ + ./internal/domain \ + ./internal/pagination \ + ./internal/response \ + ./internal/utils \ + ./internal/validator 2>&1 | tee ../reports/backend-coverage.log + coverage_exit_code=${PIPESTATUS[0]} + + if [ "$coverage_exit_code" -eq 0 ]; then + coverage_percent="$(go tool cover -func=../reports/backend-coverage.out | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" + node -e "const fs = require('node:fs'); const statements = Number(process.argv[1]); fs.writeFileSync('../reports/backend-coverage-summary.json', JSON.stringify({ package: 'backend', statements }, null, 2) + '\n');" "$coverage_percent" + { + echo "# Backend Coverage Summary" + echo + echo "| Package | Statements |" + echo "| --- | ---: |" + printf '| backend | %.2f%% |\n' "$coverage_percent" + echo + } > ../reports/backend-coverage-summary.md + cat ../reports/backend-coverage-summary.md >> "$GITHUB_STEP_SUMMARY" + fi + go test -v ./... 2>&1 | tee ../reports/backend-test.log test_exit_code=${PIPESTATUS[0]} cd .. - if [ "$test_exit_code" -ne 0 ]; then + if [ "$coverage_exit_code" -ne 0 ] || [ "$test_exit_code" -ne 0 ]; then { echo "# Backend Test Failure Report" echo echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" echo "- Job: \`backend-tests\`" - echo "- Exit Code: \`$test_exit_code\`" + echo "- Coverage Exit Code: \`$coverage_exit_code\`" + echo "- Test Exit Code: \`$test_exit_code\`" echo echo "## Command" echo "\`go test -v ./...\`" echo + if [ -f reports/backend-coverage.log ]; then + echo "## Coverage Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/backend-coverage.log + echo '```' + echo + fi echo "## Log Tail (last 200 lines)" echo '```text' tail -n 200 reports/backend-test.log @@ -374,6 +413,9 @@ jobs: } > reports/backend-test-failure-report.md fi + if [ "$coverage_exit_code" -ne 0 ]; then + exit "$coverage_exit_code" + fi exit "$test_exit_code" - name: Publish backend failure summary @@ -384,14 +426,18 @@ jobs: fi - name: Upload backend failure report artifact - if: ${{ failure() }} + if: ${{ always() }} uses: actions/upload-artifact@v3 continue-on-error: true with: - name: backend-test-failure-report + name: backend-coverage-report path: | reports/backend-test-failure-report.md reports/backend-test.log + reports/backend-coverage.log + reports/backend-coverage.out + reports/backend-coverage-summary.json + reports/backend-coverage-summary.md if-no-files-found: ignore userfront-tests: @@ -489,6 +535,86 @@ jobs: reports/userfront-test.log if-no-files-found: ignore + userfront-flutter-coverage: + needs: + - changes + - lint + if: ${{ always() && needs.changes.outputs.userfront_coverage == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_coverage == true) }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Sync userfront locales + run: | + /bin/sh ./scripts/sync_userfront_locales.sh + + - name: Run userfront Flutter coverage + run: | + cd userfront + if [ -d test ]; then + mkdir -p ../reports + set +e + flutter test --coverage 2>&1 | tee ../reports/userfront-flutter-coverage.log + test_exit_code=${PIPESTATUS[0]} + set -e + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Userfront Flutter Coverage Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-flutter-coverage\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Command" + echo "\`flutter test --coverage\`" + echo + if [ -f ../reports/userfront-flutter-coverage.log ]; then + echo "## Coverage Log Tail (last 200 lines)" + echo '```text' + tail -n 200 ../reports/userfront-flutter-coverage.log + echo '```' + fi + } > ../reports/userfront-flutter-coverage-failure-report.md + exit 1 + fi + else + echo "No userfront tests: skipping coverage (test/ directory not found)." + fi + + - name: Generate userfront Flutter coverage summary + run: | + node scripts/summarize_flutter_coverage.mjs userfront + cat reports/userfront-coverage-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Publish userfront Flutter coverage failure summary + if: ${{ failure() }} + run: | + if [ -f reports/userfront-flutter-coverage-failure-report.md ]; then + cat reports/userfront-flutter-coverage-failure-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload userfront Flutter coverage report artifact + if: ${{ always() }} + uses: actions/upload-artifact@v3 + continue-on-error: true + with: + name: userfront-flutter-coverage-report + path: | + reports/package-coverage-summary.json + reports/userfront-coverage-summary.md + reports/userfront-flutter-coverage-failure-report.md + reports/userfront-flutter-coverage.log + userfront/coverage/lcov.info + if-no-files-found: ignore + userfront-e2e-tests: needs: - changes @@ -1601,6 +1727,7 @@ jobs: - biome-check - backend-tests - userfront-tests + - userfront-flutter-coverage - userfront-e2e-tests - adminfront-vitest-coverage - devfront-vitest-coverage @@ -1621,6 +1748,20 @@ jobs: with: node-version: "24" + - name: Download backend coverage report artifact + uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: backend-coverage-report + path: badge-artifacts/backend + + - name: Download userfront Flutter coverage report artifact + uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: userfront-flutter-coverage-report + path: badge-artifacts/userfront + - name: Download adminfront Vitest coverage report artifact uses: actions/download-artifact@v3 continue-on-error: true @@ -1647,9 +1788,11 @@ jobs: LINT_RESULT: ${{ needs.lint.result }} BIOME_RESULT: ${{ needs['biome-check'].result }} BACKEND_RESULT: ${{ needs['backend-tests'].result }} + BACKEND_COVERAGE_RESULT: ${{ needs['backend-tests'].result }} USERFRONT_RESULT: ${{ needs['userfront-tests'].result }} USERFRONT_E2E_RESULT: ${{ needs['userfront-e2e-tests'].result }} USERFRONT_E2E_FULL: ${{ github.event_name == 'workflow_dispatch' && inputs.run_userfront_e2e_full == true }} + USERFRONT_COVERAGE_RESULT: ${{ needs['userfront-flutter-coverage'].result }} ADMINFRONT_COVERAGE_RESULT: ${{ needs['adminfront-vitest-coverage'].result }} DEVFRONT_COVERAGE_RESULT: ${{ needs['devfront-vitest-coverage'].result }} ORGFRONT_COVERAGE_RESULT: ${{ needs['orgfront-vitest-coverage'].result }} diff --git a/backend/internal/domain/hydra_models_test.go b/backend/internal/domain/hydra_models_test.go index d6e0c48d..8a89602f 100644 --- a/backend/internal/domain/hydra_models_test.go +++ b/backend/internal/domain/hydra_models_test.go @@ -1,6 +1,9 @@ package domain -import "testing" +import ( + "reflect" + "testing" +) func TestHydraClient_HeadlessLoginFlags(t *testing.T) { t.Run("metadata-backed headless login client is supported", func(t *testing.T) { @@ -76,3 +79,104 @@ func TestHydraClient_HeadlessLoginFlags(t *testing.T) { } }) } + +func TestHydraClientHeadlessMetadataAccessors(t *testing.T) { + t.Run("metadata values override inline values", func(t *testing.T) { + metadataJWKS := map[string]any{"keys": []any{"metadata-key"}} + client := HydraClient{ + TokenEndpointAuthMethod: "client_secret_post", + JWKSUri: "https://inline.example.com/jwks.json", + JWKS: map[string]any{"keys": []any{"inline-key"}}, + Metadata: map[string]any{ + MetadataHeadlessTokenEndpointAuthMethod: " private_key_jwt ", + MetadataHeadlessJWKSURI: " https://metadata.example.com/jwks.json ", + MetadataHeadlessJWKS: metadataJWKS, + }, + } + + if got := client.HeadlessTokenEndpointAuthMethod(); got != "private_key_jwt" { + t.Fatalf("unexpected auth method: %q", got) + } + if got := client.HeadlessJWKSURI(); got != "https://metadata.example.com/jwks.json" { + t.Fatalf("unexpected jwks uri: %q", got) + } + if got := client.HeadlessJWKS(); !reflect.DeepEqual(got, metadataJWKS) { + t.Fatalf("unexpected jwks value: %#v", got) + } + }) + + t.Run("blank or missing metadata values fall back to inline values", func(t *testing.T) { + inlineJWKS := map[string]any{"keys": []any{"inline-key"}} + client := HydraClient{ + TokenEndpointAuthMethod: " private_key_jwt ", + JWKSUri: " https://inline.example.com/jwks.json ", + JWKS: inlineJWKS, + Metadata: map[string]any{ + MetadataHeadlessTokenEndpointAuthMethod: " ", + MetadataHeadlessJWKSURI: " ", + MetadataHeadlessJWKS: nil, + }, + } + + if got := client.HeadlessTokenEndpointAuthMethod(); got != "private_key_jwt" { + t.Fatalf("unexpected auth method: %q", got) + } + if got := client.HeadlessJWKSURI(); got != "https://inline.example.com/jwks.json" { + t.Fatalf("unexpected jwks uri: %q", got) + } + if got := client.HeadlessJWKS(); !reflect.DeepEqual(got, inlineJWKS) { + t.Fatalf("unexpected jwks value: %#v", got) + } + }) +} + +func TestHydraClientBackchannelLogoutAccessors(t *testing.T) { + t.Run("metadata values override inline values", func(t *testing.T) { + inlineRequired := false + client := HydraClient{ + BackChannelLogoutURI: "https://inline.example.com/logout", + BackChannelLogoutSessionRequired: &inlineRequired, + Metadata: map[string]any{ + MetadataBackChannelLogoutURI: " https://metadata.example.com/logout ", + MetadataBackChannelLogoutSessionRequired: true, + }, + } + + if got := client.BackchannelLogoutURI(); got != "https://metadata.example.com/logout" { + t.Fatalf("unexpected logout uri: %q", got) + } + if !client.BackchannelLogoutSessionRequiredValue() { + t.Fatalf("expected metadata session_required value") + } + }) + + t.Run("blank or missing metadata values fall back to inline values", func(t *testing.T) { + inlineRequired := true + client := HydraClient{ + BackChannelLogoutURI: " https://inline.example.com/logout ", + BackChannelLogoutSessionRequired: &inlineRequired, + Metadata: map[string]any{ + MetadataBackChannelLogoutURI: " ", + MetadataBackChannelLogoutSessionRequired: "true", + }, + } + + if got := client.BackchannelLogoutURI(); got != "https://inline.example.com/logout" { + t.Fatalf("unexpected logout uri: %q", got) + } + if !client.BackchannelLogoutSessionRequiredValue() { + t.Fatalf("expected inline session_required value") + } + }) + + t.Run("missing session required defaults to false", func(t *testing.T) { + client := HydraClient{} + + if got := client.BackchannelLogoutURI(); got != "" { + t.Fatalf("unexpected logout uri: %q", got) + } + if client.BackchannelLogoutSessionRequiredValue() { + t.Fatalf("expected default session_required false") + } + }) +} diff --git a/backend/internal/domain/json_map_test.go b/backend/internal/domain/json_map_test.go new file mode 100644 index 00000000..53523b4b --- /dev/null +++ b/backend/internal/domain/json_map_test.go @@ -0,0 +1,93 @@ +package domain + +import ( + "encoding/json" + "testing" +) + +func TestJSONMapValue(t *testing.T) { + t.Run("nil map returns nil database value", func(t *testing.T) { + var payload JSONMap + + value, err := payload.Value() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if value != nil { + t.Fatalf("expected nil value, got %v", value) + } + }) + + t.Run("map marshals to JSON string", func(t *testing.T) { + payload := JSONMap{"enabled": true, "name": "baron"} + + value, err := payload.Value() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw, ok := value.(string) + if !ok { + t.Fatalf("expected string value, got %T", value) + } + + var decoded map[string]any + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatalf("value should be valid json: %v", err) + } + if decoded["enabled"] != true || decoded["name"] != "baron" { + t.Fatalf("unexpected decoded value: %#v", decoded) + } + }) +} + +func TestJSONMapScan(t *testing.T) { + t.Run("nil value becomes empty map", func(t *testing.T) { + var payload JSONMap + + if err := payload.Scan(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload == nil || len(payload) != 0 { + t.Fatalf("expected empty map, got %#v", payload) + } + }) + + t.Run("byte slice value decodes JSON", func(t *testing.T) { + var payload JSONMap + + if err := payload.Scan([]byte(`{"count":2,"name":"baron"}`)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload["count"] != float64(2) || payload["name"] != "baron" { + t.Fatalf("unexpected payload: %#v", payload) + } + }) + + t.Run("string value decodes JSON", func(t *testing.T) { + var payload JSONMap + + if err := payload.Scan(`{"active":true}`); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload["active"] != true { + t.Fatalf("unexpected payload: %#v", payload) + } + }) + + t.Run("unsupported value type returns error", func(t *testing.T) { + var payload JSONMap + + if err := payload.Scan(42); err == nil { + t.Fatalf("expected unsupported type error") + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + var payload JSONMap + + if err := payload.Scan(`{invalid`); err == nil { + t.Fatalf("expected invalid JSON error") + } + }) +} diff --git a/backend/internal/domain/model_hooks_test.go b/backend/internal/domain/model_hooks_test.go new file mode 100644 index 00000000..52dee58d --- /dev/null +++ b/backend/internal/domain/model_hooks_test.go @@ -0,0 +1,357 @@ +package domain + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func requireGeneratedUUID(t *testing.T, value string) { + t.Helper() + + if value == "" { + t.Fatalf("expected generated uuid") + } + if _, err := uuid.Parse(value); err != nil { + t.Fatalf("expected valid uuid, got %q: %v", value, err) + } +} + +func TestBeforeCreateGeneratesMissingIDs(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "api key", + run: func(t *testing.T) { + model := ApiKey{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "client consent", + run: func(t *testing.T) { + model := ClientConsent{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "identity provider config", + run: func(t *testing.T) { + model := IdentityProviderConfig{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "keto outbox", + run: func(t *testing.T) { + model := KetoOutbox{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "tenant", + run: func(t *testing.T) { + model := Tenant{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "tenant domain", + run: func(t *testing.T) { + model := TenantDomain{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "user", + run: func(t *testing.T) { + model := User{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "user group", + run: func(t *testing.T) { + model := UserGroup{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + { + name: "worksmobile resource mapping", + run: func(t *testing.T) { + model := WorksmobileResourceMapping{} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, model.ID) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, tc.run) + } +} + +func TestBeforeCreatePreservesExistingIDs(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "api key", + run: func(t *testing.T) { + model := ApiKey{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "client consent", + run: func(t *testing.T) { + model := ClientConsent{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "identity provider config", + run: func(t *testing.T) { + model := IdentityProviderConfig{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "keto outbox", + run: func(t *testing.T) { + model := KetoOutbox{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "tenant", + run: func(t *testing.T) { + model := Tenant{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "tenant domain", + run: func(t *testing.T) { + model := TenantDomain{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "user", + run: func(t *testing.T) { + model := User{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "user group", + run: func(t *testing.T) { + model := UserGroup{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + { + name: "worksmobile resource mapping", + run: func(t *testing.T) { + model := WorksmobileResourceMapping{ID: "existing-id"} + if err := model.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if model.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, tc.run) + } +} + +func TestTableNames(t *testing.T) { + tests := []struct { + name string + got string + expected string + }{ + {name: "keto outbox", got: (&KetoOutbox{}).TableName(), expected: "keto_outbox"}, + {name: "rp usage event", got: (&RPUsageEvent{}).TableName(), expected: "rp_usage_outbox"}, + {name: "rp user metadata", got: (RPUserMetadata{}).TableName(), expected: "rp_user_metadata"}, + {name: "user group", got: (&UserGroup{}).TableName(), expected: "user_groups"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.expected { + t.Fatalf("unexpected table name: got=%s expected=%s", tc.got, tc.expected) + } + }) + } +} + +func TestTenantIsActive(t *testing.T) { + tests := []struct { + name string + status string + expected bool + }{ + {name: "active", status: TenantStatusActive, expected: true}, + {name: "pending", status: TenantStatusPending, expected: false}, + {name: "suspended", status: TenantStatusSuspended, expected: false}, + {name: "deleted", status: TenantStatusDeleted, expected: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tenant := Tenant{Status: tc.status} + if got := tenant.IsActive(); got != tc.expected { + t.Fatalf("unexpected active state: got=%v expected=%v", got, tc.expected) + } + }) + } +} + +func TestRPUsageEventBeforeCreateDefaults(t *testing.T) { + event := RPUsageEvent{} + + if err := event.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, event.ID) + if event.Status != RPUsageOutboxStatusPending { + t.Fatalf("unexpected status: %s", event.Status) + } + if event.OccurredAt.IsZero() { + t.Fatalf("expected occurred_at default") + } + if event.Payload == nil { + t.Fatalf("expected empty payload default") + } +} + +func TestRPUsageEventBeforeCreatePreservesExplicitValues(t *testing.T) { + occurredAt := time.Date(2026, 5, 29, 1, 2, 3, 0, time.UTC) + event := RPUsageEvent{ + ID: "existing-id", + Status: RPUsageOutboxStatusProcessing, + OccurredAt: occurredAt, + Payload: JSONMap{"source": "test"}, + } + + if err := event.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + if event.Status != RPUsageOutboxStatusProcessing { + t.Fatalf("expected status to be preserved") + } + if !event.OccurredAt.Equal(occurredAt) { + t.Fatalf("expected occurred_at to be preserved") + } + if event.Payload["source"] != "test" { + t.Fatalf("expected payload to be preserved") + } +} + +func TestWorksmobileOutboxBeforeCreateDefaults(t *testing.T) { + outbox := WorksmobileOutbox{} + + if err := outbox.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + requireGeneratedUUID(t, outbox.ID) + if outbox.Status != WorksmobileOutboxStatusPending { + t.Fatalf("unexpected status: %s", outbox.Status) + } +} + +func TestWorksmobileOutboxBeforeCreatePreservesExplicitValues(t *testing.T) { + outbox := WorksmobileOutbox{ + ID: "existing-id", + Status: WorksmobileOutboxStatusProcessing, + } + + if err := outbox.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if outbox.ID != "existing-id" { + t.Fatalf("expected existing id to be preserved") + } + if outbox.Status != WorksmobileOutboxStatusProcessing { + t.Fatalf("expected status to be preserved") + } +} diff --git a/backend/internal/domain/shared_link_test.go b/backend/internal/domain/shared_link_test.go new file mode 100644 index 00000000..97a43e80 --- /dev/null +++ b/backend/internal/domain/shared_link_test.go @@ -0,0 +1,80 @@ +package domain + +import ( + "encoding/hex" + "testing" + "time" +) + +func TestSharedLinkBeforeCreate(t *testing.T) { + t.Run("generates id and token when missing", func(t *testing.T) { + link := SharedLink{} + + if err := link.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if link.ID == "" { + t.Fatalf("expected generated id") + } + if len(link.Token) != 64 { + t.Fatalf("expected 64-character token, got %q", link.Token) + } + if _, err := hex.DecodeString(link.Token); err != nil { + t.Fatalf("expected hex token: %v", err) + } + }) + + t.Run("preserves existing id and token", func(t *testing.T) { + link := SharedLink{ + ID: "existing-id", + Token: "existing-token", + } + + if err := link.BeforeCreate(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if link.ID != "existing-id" || link.Token != "existing-token" { + t.Fatalf("expected existing fields to be preserved: %#v", link) + } + }) +} + +func TestSharedLinkIsValid(t *testing.T) { + future := time.Now().Add(time.Hour) + past := time.Now().Add(-time.Hour) + + tests := []struct { + name string + link SharedLink + expected bool + }{ + { + name: "active link without expiration is valid", + link: SharedLink{IsActive: true}, + expected: true, + }, + { + name: "active link with future expiration is valid", + link: SharedLink{IsActive: true, ExpiresAt: &future}, + expected: true, + }, + { + name: "inactive link is invalid", + link: SharedLink{IsActive: false, ExpiresAt: &future}, + expected: false, + }, + { + name: "expired link is invalid", + link: SharedLink{IsActive: true, ExpiresAt: &past}, + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.link.IsValid(); got != tc.expected { + t.Fatalf("unexpected validity: got=%v expected=%v", got, tc.expected) + } + }) + } +} diff --git a/backend/internal/pagination/cursor_test.go b/backend/internal/pagination/cursor_test.go index eeba091a..0b7401a6 100644 --- a/backend/internal/pagination/cursor_test.go +++ b/backend/internal/pagination/cursor_test.go @@ -1,10 +1,13 @@ package pagination import ( + "encoding/base64" "testing" "time" "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" ) type testItem struct { @@ -53,3 +56,87 @@ func TestSortByKeyDescUsesIDAsTieBreaker(t *testing.T) { items[2].id, }) } + +func TestEncodeReturnsEmptyForInvalidInput(t *testing.T) { + require.Empty(t, Encode(time.Time{}, "id")) + require.Empty(t, Encode(time.Now(), " ")) +} + +func TestDecodeRejectsInvalidCursor(t *testing.T) { + t.Run("blank cursor is nil", func(t *testing.T) { + cursor, err := Decode(" ") + require.NoError(t, err) + require.Nil(t, cursor) + }) + + t.Run("invalid base64 returns error", func(t *testing.T) { + cursor, err := Decode("not base64") + require.Error(t, err) + require.Nil(t, cursor) + }) + + t.Run("invalid json returns error", func(t *testing.T) { + raw := base64.RawURLEncoding.EncodeToString([]byte("{invalid")) + cursor, err := Decode(raw) + require.Error(t, err) + require.Nil(t, cursor) + }) + + t.Run("missing timestamp returns invalid cursor", func(t *testing.T) { + raw := base64.RawURLEncoding.EncodeToString([]byte(`{"id":"abc"}`)) + cursor, err := Decode(raw) + require.EqualError(t, err, "invalid cursor") + require.Nil(t, cursor) + }) + + t.Run("missing id returns invalid cursor", func(t *testing.T) { + raw := base64.RawURLEncoding.EncodeToString([]byte(`{"timestamp":"2026-05-29T00:00:00Z"}`)) + cursor, err := Decode(raw) + require.EqualError(t, err, "invalid cursor") + require.Nil(t, cursor) + }) +} + +func TestComesAfter(t *testing.T) { + now := time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC) + cursor := &Cursor{Timestamp: now, ID: "m"} + + require.True(t, ComesAfter(now, "id", nil)) + require.True(t, ComesAfter(now.Add(-time.Second), "z", cursor)) + require.True(t, ComesAfter(now, "a", cursor)) + require.False(t, ComesAfter(now.Add(time.Second), "a", cursor)) + require.False(t, ComesAfter(now, "z", cursor)) +} + +func TestPageByCursorReturnsDecodeError(t *testing.T) { + items := []testItem{{id: "a", createdAt: time.Now()}} + + page, nextCursor, err := PageByCursor(items, 10, "not base64", func(item testItem) (time.Time, string) { + return item.createdAt, item.id + }) + + require.Error(t, err) + require.Nil(t, page) + require.Empty(t, nextCursor) +} + +func TestApplyCreatedAtIDCursor(t *testing.T) { + db, err := gorm.Open(postgres.New(postgres.Config{ + DSN: "host=localhost user=test dbname=test sslmode=disable", + }), &gorm.Config{ + DryRun: true, + DisableAutomaticPing: true, + }) + require.NoError(t, err) + + require.Same(t, db, ApplyCreatedAtIDCursor(db, nil, "created_at", "id")) + + cursor := &Cursor{ + Timestamp: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC), + ID: "cursor-id", + } + query := ApplyCreatedAtIDCursor(db.Model(&struct{}{}), cursor, "created_at", "id").Find(&[]struct{}{}) + + require.Contains(t, query.Statement.SQL.String(), "created_at < $1 OR (created_at = $2 AND id < $3)") + require.Equal(t, []any{cursor.Timestamp, cursor.Timestamp, cursor.ID}, query.Statement.Vars) +} diff --git a/backend/internal/response/error_response_test.go b/backend/internal/response/error_response_test.go index a4491cf4..9ecd70f7 100644 --- a/backend/internal/response/error_response_test.go +++ b/backend/internal/response/error_response_test.go @@ -58,6 +58,34 @@ func TestErrorWithDetailsResponseShape(t *testing.T) { } } +func TestErrorResponseShape(t *testing.T) { + app := fiber.New() + app.Get("/test", func(c *fiber.Ctx) error { + return Error(c, fiber.StatusUnauthorized, "invalid_session", "login required") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unexpected status code: %d", resp.StatusCode) + } + + body := parseBody(t, resp) + if body["error"] != "login required" { + t.Fatalf("unexpected error value: %v", body["error"]) + } + if body["code"] != "invalid_session" { + t.Fatalf("unexpected code value: %v", body["code"]) + } + if _, exists := body["details"]; exists { + t.Fatalf("details should be omitted when nil: %#v", body) + } +} + func TestStatusCodeMapping(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/utils/audit_test.go b/backend/internal/utils/audit_test.go new file mode 100644 index 00000000..d6c244af --- /dev/null +++ b/backend/internal/utils/audit_test.go @@ -0,0 +1,27 @@ +package utils + +import "testing" + +func TestParseAuditDetails(t *testing.T) { + t.Run("empty details returns error", func(t *testing.T) { + if _, err := ParseAuditDetails(""); err == nil { + t.Fatalf("expected empty details error") + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + if _, err := ParseAuditDetails("{invalid"); err == nil { + t.Fatalf("expected invalid JSON error") + } + }) + + t.Run("valid JSON returns payload", func(t *testing.T) { + payload, err := ParseAuditDetails(`{"actor":"admin","count":2}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload["actor"] != "admin" || payload["count"] != float64(2) { + t.Fatalf("unexpected payload: %#v", payload) + } + }) +} diff --git a/backend/internal/utils/client_ip_test.go b/backend/internal/utils/client_ip_test.go index 8128fc87..e16f45d8 100644 --- a/backend/internal/utils/client_ip_test.go +++ b/backend/internal/utils/client_ip_test.go @@ -22,3 +22,48 @@ func TestResolveClientIP_PrefersPublicRealIPOverPrivateForwarded(t *testing.T) { t.Fatalf("expected public real IP, got %q", got) } } + +func TestResolveClientIP_PrefersPublicRemoteIPWhenHeadersArePrivate(t *testing.T) { + got := ResolveClientIP("10.0.0.2", "192.168.0.10", "203.0.113.8:12345") + if got != "203.0.113.8" { + t.Fatalf("expected public remote IP, got %q", got) + } +} + +func TestResolveClientIP_FallsBackToRealIPWhenNoForwardedCandidates(t *testing.T) { + got := ResolveClientIP("invalid", "192.168.0.10", "bad-remote") + if got != "192.168.0.10" { + t.Fatalf("expected normalized real IP, got %q", got) + } +} + +func TestResolveClientIP_ReturnsEmptyForInvalidInputs(t *testing.T) { + got := ResolveClientIP("", "bad-real", "bad-remote") + if got != "" { + t.Fatalf("expected empty IP, got %q", got) + } +} + +func TestIsPrivateOrReservedIP(t *testing.T) { + tests := []struct { + name string + ip string + expected bool + }{ + {name: "invalid", ip: "not-an-ip", expected: false}, + {name: "public", ip: "203.0.113.8", expected: false}, + {name: "private ipv4", ip: "10.0.0.1", expected: true}, + {name: "loopback", ip: "127.0.0.1", expected: true}, + {name: "link local", ip: "169.254.1.1", expected: true}, + {name: "carrier grade nat", ip: "100.64.0.1", expected: true}, + {name: "unique local ipv6", ip: "fc00::1", expected: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsPrivateOrReservedIP(tc.ip); got != tc.expected { + t.Fatalf("unexpected private state for %s: got=%v expected=%v", tc.ip, got, tc.expected) + } + }) + } +} diff --git a/backend/internal/utils/password_policy_test.go b/backend/internal/utils/password_policy_test.go index 1bf7a817..1c49e5b8 100644 --- a/backend/internal/utils/password_policy_test.go +++ b/backend/internal/utils/password_policy_test.go @@ -22,6 +22,11 @@ func TestValidatePasswordWithPolicy(t *testing.T) { assert.NoError(t, err) }) + t.Run("Nil Policy", func(t *testing.T) { + err := ValidatePasswordWithPolicy(nil, "") + assert.NoError(t, err) + }) + t.Run("Too Short", func(t *testing.T) { err := ValidatePasswordWithPolicy(policy, "P123!") assert.Error(t, err) @@ -34,11 +39,29 @@ func TestValidatePasswordWithPolicy(t *testing.T) { assert.Contains(t, err.Error(), "소문자") }) + t.Run("Missing Uppercase", func(t *testing.T) { + err := ValidatePasswordWithPolicy(policy, "pass1234!") + assert.Error(t, err) + assert.Contains(t, err.Error(), "대문자") + }) + + t.Run("Missing Number", func(t *testing.T) { + err := ValidatePasswordWithPolicy(policy, "Password!") + assert.Error(t, err) + assert.Contains(t, err.Error(), "숫자") + }) + t.Run("Missing Symbol", func(t *testing.T) { err := ValidatePasswordWithPolicy(policy, "Pass1234") assert.Error(t, err) assert.Contains(t, err.Error(), "특수문자") }) + + t.Run("Missing Minimum Character Types", func(t *testing.T) { + err := ValidatePasswordWithPolicy(&domain.PasswordPolicy{MinLength: 4, MinCharacterTypes: 4}, "abcd") + assert.Error(t, err) + assert.Contains(t, err.Error(), "4가지") + }) } func TestGeneratePasswordWithPolicy(t *testing.T) { @@ -55,8 +78,51 @@ func TestGeneratePasswordWithPolicy(t *testing.T) { assert.NoError(t, err) assert.Len(t, password, 16) - // Generated password must satisfy the policy err = ValidatePasswordWithPolicy(policy, password) assert.NoError(t, err, "Generated password '%s' does not satisfy policy", password) }) + + t.Run("Nil Policy Uses Default Length", func(t *testing.T) { + password, err := GeneratePasswordWithPolicy(nil) + assert.NoError(t, err) + assert.Len(t, password, 12) + }) + + t.Run("Minimum Character Types Adds Optional Categories", func(t *testing.T) { + policy := &domain.PasswordPolicy{ + MinLength: 4, + Lowercase: true, + MinCharacterTypes: 4, + } + + password, err := GeneratePasswordWithPolicy(policy) + assert.NoError(t, err) + assert.Len(t, password, 4) + assert.NoError(t, ValidatePasswordWithPolicy(policy, password)) + }) + + t.Run("Required Categories Raise Short Minimum Length", func(t *testing.T) { + policy := &domain.PasswordPolicy{ + MinLength: 1, + Lowercase: true, + Uppercase: true, + Number: true, + NonAlphanumeric: true, + } + + password, err := GeneratePasswordWithPolicy(policy) + assert.NoError(t, err) + assert.Len(t, password, 4) + assert.NoError(t, ValidatePasswordWithPolicy(policy, password)) + }) +} + +func TestPasswordPolicyRandomHelpersRejectInvalidInput(t *testing.T) { + _, err := randomIndex(0) + assert.Error(t, err) + + _, err = randomChar("") + assert.Error(t, err) + + assert.NoError(t, shuffleRunes([]rune("a"))) } diff --git a/backend/internal/validator/schema_validator_test.go b/backend/internal/validator/schema_validator_test.go index 5bcf405f..91393a13 100644 --- a/backend/internal/validator/schema_validator_test.go +++ b/backend/internal/validator/schema_validator_test.go @@ -2,13 +2,15 @@ package validator import ( "baron-sso-backend/internal/domain" + "errors" "net/http" "testing" ) // MockProvider는 IdentityProvider 인터페이스를 구현하는 테스트용 구조체입니다. type MockProvider struct { - Supported []string + Supported []string + MetadataErr error } func (m *MockProvider) Name() string { @@ -16,6 +18,9 @@ func (m *MockProvider) Name() string { } func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) { + if m.MetadataErr != nil { + return nil, m.MetadataErr + } return &domain.IDPMetadata{ SupportedFields: m.Supported, }, nil @@ -118,3 +123,23 @@ func TestValidateIDPCompatibility(t *testing.T) { }) } } + +func TestValidateIDPCompatibilityMetadataError(t *testing.T) { + mockIDP := &MockProvider{MetadataErr: errors.New("metadata unavailable")} + + err := ValidateIDPCompatibility(domain.BrokerUser{}, mockIDP) + if err == nil { + t.Fatalf("expected metadata error") + } + if got := err.Error(); got != "failed to fetch metadata from IDP MockIDP: metadata unavailable" { + t.Fatalf("unexpected error: %s", got) + } +} + +func TestValidateIDPCompatibilityPointerModel(t *testing.T) { + mockIDP := &MockProvider{Supported: []string{"id", "email", "grade", "department"}} + + if err := ValidateIDPCompatibility(&domain.BrokerUser{}, mockIDP); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/scripts/summarize_flutter_coverage.mjs b/scripts/summarize_flutter_coverage.mjs new file mode 100644 index 00000000..263a394d --- /dev/null +++ b/scripts/summarize_flutter_coverage.mjs @@ -0,0 +1,115 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const packageName = process.argv[2] || "userfront"; +const lcovPath = path.join(repoRoot, packageName, "coverage", "lcov.info"); +const reportsDir = path.join(repoRoot, "reports"); + +const excludedSourceFiles = new Set(["lib/main.dart", "lib/i18n_data.dart"]); + +function normalizeSourcePath(sourcePath) { + const normalized = sourcePath.replace(/\\/g, "/"); + const packagePrefix = `${packageName}/`; + return normalized.startsWith(packagePrefix) + ? normalized.slice(packagePrefix.length) + : normalized; +} + +function shouldExcludeSource(sourcePath) { + const normalized = normalizeSourcePath(sourcePath); + return ( + excludedSourceFiles.has(normalized) || + normalized.endsWith(".g.dart") || + normalized.endsWith(".freezed.dart") + ); +} + +function parseLcov(raw) { + const records = []; + let current = null; + + for (const line of raw.split(/\r?\n/)) { + if (line.startsWith("SF:")) { + current = { sourceFile: normalizeSourcePath(line.slice(3)), lines: [] }; + continue; + } + + if (!current) continue; + + if (line.startsWith("DA:")) { + const [, hitsValue] = line.slice(3).split(","); + const hits = Number(hitsValue); + current.lines.push(Number.isFinite(hits) ? hits : 0); + continue; + } + + if (line === "end_of_record") { + records.push(current); + current = null; + } + } + + if (current) { + records.push(current); + } + + return records; +} + +function formatPct(value) { + return `${value.toFixed(2)}%`; +} + +function renderMarkdown(row) { + return [ + "# Userfront Flutter Coverage Summary", + "", + "| Package | Lines | Covered / Total | LCOV |", + "| --- | ---: | ---: | --- |", + `| ${row.package} | ${formatPct(row.lines)} | ${row.coveredLines} / ${row.totalLines} | ${row.lcovPath} |`, + "", + "Coverage excludes Flutter bootstrap/generated files: `lib/main.dart`, `lib/i18n_data.dart`, `*.g.dart`, `*.freezed.dart`.", + "", + ].join("\n"); +} + +const lcov = await readFile(lcovPath, "utf8"); +const includedRecords = parseLcov(lcov).filter( + (record) => !shouldExcludeSource(record.sourceFile), +); + +const totalLines = includedRecords.reduce( + (total, record) => total + record.lines.length, + 0, +); +const coveredLines = includedRecords.reduce( + (total, record) => total + record.lines.filter((hits) => hits > 0).length, + 0, +); +const lineCoverage = totalLines === 0 ? 0 : (coveredLines / totalLines) * 100; + +const row = { + package: packageName, + statements: lineCoverage, + branches: null, + functions: null, + lines: lineCoverage, + coveredLines, + totalLines, + summaryPath: "reports/package-coverage-summary.json", + htmlPath: null, + lcovPath: `${packageName}/coverage/lcov.info`, +}; + +await mkdir(reportsDir, { recursive: true }); +await writeFile( + path.join(reportsDir, "package-coverage-summary.json"), + `${JSON.stringify({ packages: [row] }, null, 2)}\n`, +); +await writeFile( + path.join(reportsDir, `${packageName}-coverage-summary.md`), + renderMarkdown(row), +); + +console.log(renderMarkdown(row)); diff --git a/scripts/update_code_check_badges.mjs b/scripts/update_code_check_badges.mjs index 854c80d8..afc01b88 100644 --- a/scripts/update_code_check_badges.mjs +++ b/scripts/update_code_check_badges.mjs @@ -94,7 +94,8 @@ function colorForParts(parts) { const normalized = parts.map(normalizeResult); if (normalized.includes("failure")) return resultStyles.failure.color; if (normalized.includes("cancelled")) return resultStyles.cancelled.color; - if (normalized.every((part) => part === "success")) return resultStyles.success.color; + if (normalized.every((part) => part === "success")) + return resultStyles.success.color; return resultStyles.unknown.color; } @@ -161,7 +162,14 @@ async function findCoverageSummaries(directory) { for (const entry of entries) { const entryPath = path.join(directory, entry.name); - if (entry.isFile() && entry.name === "vitest-coverage-summary.json") { + if ( + entry.isFile() && + [ + "backend-coverage-summary.json", + "package-coverage-summary.json", + "vitest-coverage-summary.json", + ].includes(entry.name) + ) { results.push(entryPath); continue; } @@ -216,7 +224,13 @@ function coveragePart(result, statements) { }; } -function updatePackageBadge(manifest, key, testResult, coverageResult, statements) { +function updatePackageBadge( + manifest, + key, + testResult, + coverageResult, + statements, +) { if (!badgeDefinitions[key]) return; const test = normalizeResult(testResult); const coverage = coveragePart(coverageResult, statements); @@ -224,13 +238,14 @@ function updatePackageBadge(manifest, key, testResult, coverageResult, statement ...(manifest.badges[key] ?? badgeDefinitions[key]), label: badgeDefinitions[key].label, message: `${compactResult(test)} | ${coverage.message}`, - color: test === "failure" || coverage.result === "failure" - ? resultStyles.failure.color - : test === "cancelled" || coverage.result === "cancelled" - ? resultStyles.cancelled.color - : coverage.result === "success" - ? coverage.color - : colorForParts([test, coverage.result]), + color: + test === "failure" || coverage.result === "failure" + ? resultStyles.failure.color + : test === "cancelled" || coverage.result === "cancelled" + ? resultStyles.cancelled.color + : coverage.result === "success" + ? coverage.color + : colorForParts([test, coverage.result]), }; } @@ -332,6 +347,7 @@ const browserResults = { const legacyCoverageResult = process.env.COVERAGE_RESULT; const coverageJobResults = { + backend: process.env.BACKEND_COVERAGE_RESULT, userfront: process.env.USERFRONT_COVERAGE_RESULT, adminfront: process.env.ADMINFRONT_COVERAGE_RESULT || legacyCoverageResult, devfront: process.env.DEVFRONT_COVERAGE_RESULT || legacyCoverageResult, @@ -367,7 +383,6 @@ if (process.env.BADGE_UPDATE_CODE_CHECK !== "false") { } updateResultBadge(manifest, "biome", jobResults.biome); -updateCompactResultBadge(manifest, "backend-tests", jobResults.backend); const coverageSummaries = process.env.COVERAGE_SUMMARY_PATH ? [process.env.COVERAGE_SUMMARY_PATH] @@ -378,6 +393,21 @@ for (const summaryPath of coverageSummaries) { for (const row of coverageSummary?.packages ?? []) { coverageByPackage.set(row.package, row.statements); } + if (coverageSummary?.package) { + coverageByPackage.set(coverageSummary.package, coverageSummary.statements); + } +} + +if (coverageJobResults.backend) { + updatePackageBadge( + manifest, + "backend-tests", + jobResults.backend, + coverageJobResults.backend, + coverageByPackage.get("backend"), + ); +} else { + updateCompactResultBadge(manifest, "backend-tests", jobResults.backend); } for (const [key, testResult, coverageResult] of [ diff --git a/test/code_check_badge_branch_policy_test.sh b/test/code_check_badge_branch_policy_test.sh index 5093aa6e..1de4f561 100644 --- a/test/code_check_badge_branch_policy_test.sh +++ b/test/code_check_badge_branch_policy_test.sh @@ -30,6 +30,7 @@ assert_contains "$WORKFLOW_FILE" 'push origin HEAD:${BADGE_BRANCH}' assert_contains "$WORKFLOW_FILE" 'BADGE_SOURCE_SHA: ${{ github.sha }}' assert_contains "$WORKFLOW_FILE" 'BADGE_LATEST_DIR="${BADGE_WORKTREE}/latest"' assert_contains "$WORKFLOW_FILE" 'BADGE_SHA_DIR="${BADGE_WORKTREE}/dev/${GITHUB_SHA}"' +assert_contains "$WORKFLOW_FILE" "userfront-flutter-coverage:" assert_contains "$WORKFLOW_FILE" "adminfront-vitest-coverage:" assert_contains "$WORKFLOW_FILE" "devfront-vitest-coverage:" assert_contains "$WORKFLOW_FILE" "orgfront-vitest-coverage:" @@ -39,6 +40,8 @@ fi assert_contains "$WORKFLOW_FILE" "ADMINFRONT_COVERAGE_RESULT: \${{ needs['adminfront-vitest-coverage'].result }}" assert_contains "$WORKFLOW_FILE" "DEVFRONT_COVERAGE_RESULT: \${{ needs['devfront-vitest-coverage'].result }}" assert_contains "$WORKFLOW_FILE" "ORGFRONT_COVERAGE_RESULT: \${{ needs['orgfront-vitest-coverage'].result }}" +assert_contains "$WORKFLOW_FILE" "USERFRONT_COVERAGE_RESULT: \${{ needs['userfront-flutter-coverage'].result }}" +assert_contains "$WORKFLOW_FILE" "name: userfront-flutter-coverage-report" assert_contains "$WORKFLOW_FILE" "name: adminfront-vitest-coverage-report" assert_contains "$WORKFLOW_FILE" "name: devfront-vitest-coverage-report" assert_contains "$WORKFLOW_FILE" "name: orgfront-vitest-coverage-report" diff --git a/test/summarize_userfront_coverage_test.sh b/test/summarize_userfront_coverage_test.sh new file mode 100644 index 00000000..942363c3 --- /dev/null +++ b/test/summarize_userfront_coverage_test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +mkdir -p "$WORK_DIR/userfront/coverage" + +cat > "$WORK_DIR/userfront/coverage/lcov.info" <<'LCOV' +SF:lib/main.dart +DA:1,1 +DA:2,0 +LF:2 +LH:1 +end_of_record +SF:lib/i18n_data.dart +DA:1,0 +LF:1 +LH:0 +end_of_record +SF:lib/features/auth/domain/login_challenge_resolver.dart +DA:10,1 +DA:11,1 +DA:12,0 +LF:3 +LH:2 +end_of_record +SF:lib/core/services/logout_service.dart +DA:20,1 +DA:21,1 +LF:2 +LH:2 +end_of_record +LCOV + +( + cd "$WORK_DIR" + node "$ROOT_DIR/scripts/summarize_flutter_coverage.mjs" userfront +) + +node - "$WORK_DIR/reports/package-coverage-summary.json" <<'NODE' +const fs = require("node:fs"); + +const summary = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const row = summary.packages[0]; + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +} + +assertEqual(row.package, "userfront", "package name"); +assertEqual(row.statements, 80, "line coverage must exclude bootstrap/generated files"); +assertEqual(row.lines, 80, "lines coverage must match statements for Flutter lcov"); +assertEqual(row.coveredLines, 4, "covered lines"); +assertEqual(row.totalLines, 5, "total lines"); +assertEqual(row.lcovPath, "userfront/coverage/lcov.info", "lcov path"); +NODE + +grep -Fq "| userfront | 80.00% | 4 / 5 | userfront/coverage/lcov.info |" \ + "$WORK_DIR/reports/userfront-coverage-summary.md" + +echo "OK: userfront Flutter LCOV summary is generated" diff --git a/test/update_code_check_badges_package_coverage_test.sh b/test/update_code_check_badges_package_coverage_test.sh index dc3dda46..9fb2a5ad 100644 --- a/test/update_code_check_badges_package_coverage_test.sh +++ b/test/update_code_check_badges_package_coverage_test.sh @@ -6,6 +6,7 @@ WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT mkdir -p "$WORK_DIR/docs/badges" +mkdir -p "$WORK_DIR/badge-artifacts/backend/reports" mkdir -p "$WORK_DIR/badge-artifacts/userfront/reports" mkdir -p "$WORK_DIR/badge-artifacts/adminfront/reports" mkdir -p "$WORK_DIR/badge-artifacts/orgfront/reports" @@ -47,7 +48,14 @@ cat > "$WORK_DIR/docs/badges/badges.json" <<'JSON' } JSON -cat > "$WORK_DIR/badge-artifacts/userfront/reports/vitest-coverage-summary.json" <<'JSON' +cat > "$WORK_DIR/badge-artifacts/backend/reports/backend-coverage-summary.json" <<'JSON' +{ + "package": "backend", + "statements": 90.0 +} +JSON + +cat > "$WORK_DIR/badge-artifacts/userfront/reports/package-coverage-summary.json" <<'JSON' { "packages": [ { @@ -86,6 +94,7 @@ run_badge_update() { LINT_RESULT=success \ BIOME_RESULT=success \ BACKEND_RESULT=success \ + BACKEND_COVERAGE_RESULT=success \ USERFRONT_RESULT=success \ USERFRONT_E2E_RESULT=success \ USERFRONT_E2E_CHROMIUM_DESKTOP_RESULT=success \ @@ -124,7 +133,7 @@ function assertEqual(actual, expected, message) { } assertEqual(badges["backend-tests"].label, "backend", "backend badge label must be compact"); -assertEqual(badges["backend-tests"].message, "pass", "backend test badge must use backend job result"); +assertEqual(badges["backend-tests"].message, "pass | 90.00%", "backend badge must combine test result and coverage"); assertEqual(badges.userfront.message, "pass | 85.40%", "userfront badge must combine fast E2E result and coverage"); assertEqual(badges.adminfront.message, "pass | 82.34%", "adminfront badge must combine E2E result and coverage"); assertEqual(badges.devfront.message, "pass | fail", "devfront badge must fail coverage independently"); diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 6f9ae984..cebe659e 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -6,7 +6,18 @@ import 'auth_token_store.dart'; import 'log_policy.dart'; import 'runtime_env.dart'; +typedef AuthProxyHttpClientFactory = + http.Client Function({bool withCredentials}); + class AuthProxyService { + static AuthProxyHttpClientFactory? _httpClientFactoryForTesting; + + static void debugSetHttpClientFactoryForTesting( + AuthProxyHttpClientFactory? factory, + ) { + _httpClientFactoryForTesting = factory; + } + static String get _baseUrl => runtimeBackendUrl(); static bool get _isProd { @@ -32,9 +43,71 @@ class AuthProxyService { ); } + static http.Client _createClient({bool withCredentials = false}) { + final factory = _httpClientFactoryForTesting; + if (factory != null) { + return factory(withCredentials: withCredentials); + } + return createHttpClient(withCredentials: withCredentials); + } + + static Future _get( + Uri url, { + Map? headers, + bool withCredentials = false, + }) async { + final client = _createClient(withCredentials: withCredentials); + try { + return await client.get(url, headers: headers); + } finally { + client.close(); + } + } + + static Future _post( + Uri url, { + Map? headers, + Object? body, + bool withCredentials = false, + }) async { + final client = _createClient(withCredentials: withCredentials); + try { + return await client.post(url, headers: headers, body: body); + } finally { + client.close(); + } + } + + static Future _patch( + Uri url, { + Map? headers, + Object? body, + bool withCredentials = false, + }) async { + final client = _createClient(withCredentials: withCredentials); + try { + return await client.patch(url, headers: headers, body: body); + } finally { + client.close(); + } + } + + static Future _delete( + Uri url, { + Map? headers, + bool withCredentials = false, + }) async { + final client = _createClient(withCredentials: withCredentials); + try { + return await client.delete(url, headers: headers); + } finally { + client.close(); + } + } + static Future> fetchPasswordPolicy() async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy'); - final response = await http.get(url); + final response = await _get(url); if (response.statusCode == 200) { return jsonDecode(response.body); } else { @@ -47,24 +120,20 @@ class AuthProxyService { static Future> checkCookieSession() async { final url = Uri.parse('$_baseUrl/api/v1/user/me'); - final client = createHttpClient(withCredentials: true); - try { - final response = await client.get( - url, - headers: {'Content-Type': 'application/json'}, - ); + final response = await _get( + url, + headers: {'Content-Type': 'application/json'}, + withCredentials: true, + ); - if (response.statusCode == 200) { - return jsonDecode(response.body); - } - throw _error( - 'err.userfront.auth_proxy.profile_load', - 'Failed to load the profile: {{error}}', - detail: response.body, - ); - } finally { - client.close(); + if (response.statusCode == 200) { + return jsonDecode(response.body); } + throw _error( + 'err.userfront.auth_proxy.profile_load', + 'Failed to load the profile: {{error}}', + detail: response.body, + ); } static Future> getMe({ @@ -72,25 +141,24 @@ class AuthProxyService { bool useCookie = true, }) async { final url = Uri.parse('$_baseUrl/api/v1/user/me'); - final client = createHttpClient(withCredentials: useCookie); - try { - final headers = {'Content-Type': 'application/json'}; - if (!useCookie && token != null && token.isNotEmpty) { - headers['Authorization'] = 'Bearer $token'; - } - final response = await client.get(url, headers: headers); - - if (response.statusCode == 200) { - return jsonDecode(response.body); - } - throw _error( - 'err.userfront.auth_proxy.profile_load', - 'Failed to load the profile: {{error}}', - detail: response.body, - ); - } finally { - client.close(); + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; } + final response = await _get( + url, + headers: headers, + withCredentials: useCookie, + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } + throw _error( + 'err.userfront.auth_proxy.profile_load', + 'Failed to load the profile: {{error}}', + detail: response.body, + ); } static Future getSessionStatus({ @@ -98,22 +166,21 @@ class AuthProxyService { bool useCookie = false, }) async { final url = Uri.parse('$_baseUrl/api/v1/user/me'); - final client = createHttpClient(withCredentials: useCookie); - try { - final headers = {'Content-Type': 'application/json'}; - if (!useCookie && token != null && token.isNotEmpty) { - headers['Authorization'] = 'Bearer $token'; - } - final response = await client.get(url, headers: headers); - return response.statusCode; - } finally { - client.close(); + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; } + final response = await _get( + url, + headers: headers, + withCredentials: useCookie, + ); + return response.statusCode; } static Future> getTenantInfo() async { final url = Uri.parse('$_baseUrl/api/v1/auth/tenant-info'); - final response = await http.get(url); + final response = await _get(url); if (response.statusCode == 200) { return jsonDecode(response.body); } else { @@ -144,7 +211,7 @@ class AuthProxyService { body['codeOnly'] = true; } - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(body), @@ -166,7 +233,7 @@ class AuthProxyService { ) async { final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'pendingRef': pendingRef}), @@ -191,7 +258,7 @@ class AuthProxyService { }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}), @@ -225,7 +292,7 @@ class AuthProxyService { payload['pendingRef'] = pendingRef; } - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(payload), @@ -246,22 +313,21 @@ class AuthProxyService { final url = Uri.parse('$_baseUrl/api/v1/user/sessions/$sessionId'); final useCookie = AuthTokenStore.usesCookie(); final token = AuthTokenStore.getToken(); - final client = createHttpClient(withCredentials: useCookie); - try { - final headers = {'Content-Type': 'application/json'}; - if (!useCookie && token != null && token.isNotEmpty) { - headers['Authorization'] = 'Bearer $token'; - } - final response = await client.delete(url, headers: headers); - if (response.statusCode != 200) { - throw _error( - 'err.userfront.dashboard.sessions.revoke', - 'Failed to revoke the session: {{error}}', - detail: response.body, - ); - } - } finally { - client.close(); + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await _delete( + url, + headers: headers, + withCredentials: useCookie, + ); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.revoke', + 'Failed to revoke the session: {{error}}', + detail: response.body, + ); } } @@ -269,35 +335,34 @@ class AuthProxyService { final url = Uri.parse('$_baseUrl/api/v1/user/sessions'); final useCookie = AuthTokenStore.usesCookie(); final token = AuthTokenStore.getToken(); - final client = createHttpClient(withCredentials: useCookie); - try { - final headers = {'Content-Type': 'application/json'}; - if (!useCookie && token != null && token.isNotEmpty) { - headers['Authorization'] = 'Bearer $token'; - } - final response = await client.get(url, headers: headers); - if (response.statusCode != 200) { - throw _error( - 'err.userfront.dashboard.sessions.load', - 'Failed to load the active sessions: {{error}}', - detail: response.body, - ); - } + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await _get( + url, + headers: headers, + withCredentials: useCookie, + ); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.load', + 'Failed to load the active sessions: {{error}}', + detail: response.body, + ); + } - final body = jsonDecode(response.body) as Map; - final items = (body['items'] as List?) ?? const []; - for (final item in items.whereType>()) { - if (item['is_current'] == true) { - final sessionId = item['session_id']?.toString().trim() ?? ''; - if (sessionId.isNotEmpty) { - return sessionId; - } + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? const []; + for (final item in items.whereType>()) { + if (item['is_current'] == true) { + final sessionId = item['session_id']?.toString().trim() ?? ''; + if (sessionId.isNotEmpty) { + return sessionId; } } - return null; - } finally { - client.close(); } + return null; } static Future> verifyLoginShortCode( @@ -306,7 +371,7 @@ class AuthProxyService { }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}), @@ -337,7 +402,7 @@ class AuthProxyService { 'login_challenge': loginChallenge, }; - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(payload), @@ -360,29 +425,24 @@ class AuthProxyService { final url = Uri.parse( '$_baseUrl/api/v1/auth/consent', ).replace(queryParameters: {'consent_challenge': consentChallenge}); - final client = createHttpClient(withCredentials: true); - try { - final response = await client.get( - url, - headers: {'Content-Type': 'application/json'}, - ); + final response = await _get( + url, + headers: {'Content-Type': 'application/json'}, + withCredentials: true, + ); - if (response.statusCode == 200) { - return jsonDecode(response.body); - } else { - final errorBody = jsonDecode(response.body); - final rawDetails = errorBody['details']; - throw AuthProxyException( - errorCode: (errorBody['code'] ?? '').toString(), - message: - (errorBody['error'] ?? - tr('err.userfront.auth_proxy.consent_fetch')) - .toString(), - details: rawDetails is Map ? rawDetails : null, - ); - } - } finally { - client.close(); + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + final rawDetails = errorBody['details']; + throw AuthProxyException( + errorCode: (errorBody['code'] ?? '').toString(), + message: + (errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch')) + .toString(), + details: rawDetails is Map ? rawDetails : null, + ); } } @@ -396,24 +456,20 @@ class AuthProxyService { body['grant_scope'] = grantScope; } - final client = createHttpClient(withCredentials: true); - try { - final response = await client.post( - url, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(body), - ); + final response = await _post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + withCredentials: true, + ); - if (response.statusCode == 200) { - return jsonDecode(response.body); - } else { - final errorBody = jsonDecode(response.body); - throw Exception( - errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'), - ); - } - } finally { - client.close(); + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception( + errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'), + ); } } @@ -423,24 +479,20 @@ class AuthProxyService { final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject'); final body = {'consent_challenge': consentChallenge}; - final client = createHttpClient(withCredentials: true); - try { - final response = await client.post( - url, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(body), - ); + final response = await _post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + withCredentials: true, + ); - if (response.statusCode == 200) { - return jsonDecode(response.body); - } else { - final errorBody = jsonDecode(response.body); - throw Exception( - errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'), - ); - } - } finally { - client.close(); + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception( + errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'), + ); } } @@ -453,24 +505,20 @@ class AuthProxyService { if (token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; } - final client = createHttpClient(withCredentials: true); - try { - final response = await client.post( - url, - headers: headers, - body: jsonEncode({'login_challenge': loginChallenge}), - ); + final response = await _post( + url, + headers: headers, + body: jsonEncode({'login_challenge': loginChallenge}), + withCredentials: true, + ); - if (response.statusCode == 200) { - return jsonDecode(response.body); - } else { - final errorBody = jsonDecode(response.body); - throw Exception( - errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'), - ); - } - } finally { - client.close(); + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception( + errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'), + ); } } @@ -479,7 +527,7 @@ class AuthProxyService { bool? drySend, }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ @@ -514,7 +562,7 @@ class AuthProxyService { final url = Uri.parse( '$_baseUrl/api/v1/auth/password/reset/complete', ).replace(queryParameters: query); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'newPassword': newPassword}), @@ -534,7 +582,7 @@ class AuthProxyService { static Future sendSms(String phoneNumber) async { final url = Uri.parse('$_baseUrl/api/v1/auth/sms'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'phoneNumber': phoneNumber}), @@ -555,7 +603,7 @@ class AuthProxyService { ) async { final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}), @@ -574,7 +622,7 @@ class AuthProxyService { static Future> initQrLogin() async { final url = Uri.parse('$_baseUrl/api/v1/auth/qr/init'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, ); @@ -592,7 +640,7 @@ class AuthProxyService { static Future> pollQrStatus(String pendingRef) async { final url = Uri.parse('$_baseUrl/api/v1/auth/qr/poll'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'pendingRef': pendingRef}), @@ -624,39 +672,26 @@ class AuthProxyService { payload['token'] = token; } - http.Client? client; - try { - if (withCredentials) { - client = createHttpClient(withCredentials: true); - } - final response = await (client != null - ? client.post( - url, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(payload), - ) - : http.post( - url, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(payload), - )); + final response = await _post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + withCredentials: withCredentials, + ); - if (response.statusCode != 200) { - throw _error( - 'err.userfront.auth_proxy.qr_approve', - 'Failed to approve QR login: {{error}}', - detail: response.body, - ); - } - } finally { - client?.close(); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.auth_proxy.qr_approve', + 'Failed to approve QR login: {{error}}', + detail: response.body, + ); } } static Future checkAdminAuth(String adminPassword) async { final url = Uri.parse('$_baseUrl/api/v1/admin/check'); try { - final response = await http.get( + final response = await _get( url, headers: { 'Content-Type': 'application/json', @@ -678,7 +713,7 @@ class AuthProxyService { }) async { final url = Uri.parse('$_baseUrl/api/v1/admin/users'); - final response = await http.post( + final response = await _post( url, headers: { 'Content-Type': 'application/json', @@ -710,7 +745,7 @@ class AuthProxyService { uri = uri.replace(queryParameters: {'text': query}); } - final response = await http.get( + final response = await _get( uri, headers: { 'Content-Type': 'application/json', @@ -734,7 +769,7 @@ class AuthProxyService { final encodedId = Uri.encodeComponent(loginId); final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId'); - final response = await http.delete( + final response = await _delete( url, headers: { 'Content-Type': 'application/json', @@ -759,7 +794,7 @@ class AuthProxyService { final encodedId = Uri.encodeComponent(loginId); final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status'); - final response = await http.patch( + final response = await _patch( url, headers: { 'Content-Type': 'application/json', @@ -792,7 +827,7 @@ class AuthProxyService { if (phone != null) body['phone'] = phone; if (displayName != null) body['displayName'] = displayName; - final response = await http.patch( + final response = await _patch( url, headers: { 'Content-Type': 'application/json', @@ -815,26 +850,25 @@ class AuthProxyService { final useCookie = AuthTokenStore.usesCookie(); final token = AuthTokenStore.getToken(); - final client = createHttpClient(withCredentials: useCookie); final headers = {'Content-Type': 'application/json'}; if (!useCookie && token != null) { headers['Authorization'] = 'Bearer $token'; } - try { - final response = await client.get(url, headers: headers); + final response = await _get( + url, + headers: headers, + withCredentials: useCookie, + ); - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return data['items'] ?? []; - } else { - throw _error( - 'err.userfront.auth_proxy.linked_apps_load', - 'Failed to load linked applications.', - ); - } - } finally { - client.close(); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['items'] ?? []; + } else { + throw _error( + 'err.userfront.auth_proxy.linked_apps_load', + 'Failed to load linked applications.', + ); } } @@ -843,24 +877,22 @@ class AuthProxyService { final useCookie = AuthTokenStore.usesCookie(); final token = AuthTokenStore.getToken(); - final client = createHttpClient(withCredentials: useCookie); final headers = {'Content-Type': 'application/json'}; if (!useCookie && token != null) { headers['Authorization'] = 'Bearer $token'; } - try { - final response = await client.delete(url, headers: headers); + final response = await _delete( + url, + headers: headers, + withCredentials: useCookie, + ); - if (response.statusCode != 200) { - final errorBody = jsonDecode(response.body); - throw Exception( - errorBody['error'] ?? - tr('err.userfront.auth_proxy.linked_app_revoke'), - ); - } - } finally { - client.close(); + if (response.statusCode != 200) { + final errorBody = jsonDecode(response.body); + throw Exception( + errorBody['error'] ?? tr('err.userfront.auth_proxy.linked_app_revoke'), + ); } } @@ -888,7 +920,7 @@ class AuthProxyService { final sanitizedMessage = LogPolicy.sanitizeMessage(message); final sanitizedData = data == null ? null : LogPolicy.sanitizeData(data); try { - await http.post( + await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ @@ -945,7 +977,7 @@ class AuthProxyService { static Future checkEmailAvailability(String email) async { final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-email'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'email': email}), @@ -967,7 +999,7 @@ class AuthProxyService { if (tenantSlug != null && tenantSlug.isNotEmpty) { bodyData['tenantSlug'] = tenantSlug; } - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(bodyData), @@ -997,7 +1029,7 @@ class AuthProxyService { } final url = Uri.parse(uriString); - final response = await http.get(url); + final response = await _get(url); if (response.statusCode == 200) { final List list = jsonDecode(response.body); return list.cast>(); @@ -1009,7 +1041,7 @@ class AuthProxyService { final path = type == 'email' ? 'send-email-code' : 'send-sms-code'; final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'target': target}), @@ -1031,7 +1063,7 @@ class AuthProxyService { ) async { final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({'target': target, 'type': type, 'code': code}), @@ -1055,7 +1087,7 @@ class AuthProxyService { }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/signup'); - final response = await http.post( + final response = await _post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ diff --git a/userfront/test/auth_proxy_service_test.dart b/userfront/test/auth_proxy_service_test.dart new file mode 100644 index 00000000..2bb32714 --- /dev/null +++ b/userfront/test/auth_proxy_service_test.dart @@ -0,0 +1,589 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:userfront/core/services/auth_proxy_service.dart'; +import 'package:userfront/core/services/auth_token_store.dart'; + +void main() { + late _RecordingClient client; + + setUp(() { + client = _RecordingClient(); + AuthTokenStore.clear(); + AuthProxyService.debugSetHttpClientFactoryForTesting(({ + bool withCredentials = false, + }) { + client.withCredentialsCalls.add(withCredentials); + return client; + }); + }); + + tearDown(() { + AuthProxyService.debugSetHttpClientFactoryForTesting(null); + AuthTokenStore.clear(); + }); + + group('AuthProxyService HTTP contract', () { + test('getMe는 bearer token과 cookie mode를 구분한다', () async { + client.enqueueJson({'id': 'user-1'}); + + final result = await AuthProxyService.getMe( + token: 'jwt-token', + useCookie: false, + ); + + expect(result['id'], 'user-1'); + expect(client.withCredentialsCalls, [false]); + expect( + client.requests.single.headers['Authorization'], + 'Bearer jwt-token', + ); + expect(client.closedCount, 1); + }); + + test('checkCookieSession은 credential client로 profile을 조회한다', () async { + client.enqueueJson({'email': 'user@example.com'}); + + final result = await AuthProxyService.checkCookieSession(); + + expect(result['email'], 'user@example.com'); + expect(client.withCredentialsCalls, [true]); + expect(client.requests.single.url.path, '/api/v1/user/me'); + }); + + test('initEnchantedLink는 개발 환경에서 drySend와 codeOnly를 전송한다', () async { + client.enqueueJson({'pendingRef': 'pending-1'}); + + await AuthProxyService.initEnchantedLink( + 'user@example.com', + method: 'email', + codeOnly: true, + drySend: true, + ); + + final body = client.lastJsonBody; + expect(body['loginId'], 'user@example.com'); + expect(body['method'], 'email'); + expect(body['codeOnly'], isTrue); + expect(body['drySend'], isTrue); + expect(body['uri'], isA()); + }); + + test('poll 계열은 pending 상태 400 응답 body를 정상 결과로 반환한다', () async { + client.enqueueJson({'status': 'pending'}, statusCode: 400); + + final result = await AuthProxyService.pollEnchantedLink('pending-1'); + + expect(result['status'], 'pending'); + expect(client.lastJsonBody['pendingRef'], 'pending-1'); + }); + + test('verifyLoginCode는 pendingRef가 있을 때만 payload에 포함한다', () async { + client.enqueueJson({'ok': true}); + + await AuthProxyService.verifyLoginCode( + 'user@example.com', + '123456', + pendingRef: 'pending-1', + verifyOnly: true, + ); + + expect(client.lastJsonBody, { + 'loginId': 'user@example.com', + 'code': '123456', + 'verifyOnly': true, + 'pendingRef': 'pending-1', + }); + }); + + test('fetchCurrentSessionId는 현재 세션만 반환하고 없으면 null을 반환한다', () async { + AuthTokenStore.setToken('jwt-token'); + client.enqueueJson({ + 'items': [ + {'session_id': 'old-session', 'is_current': false}, + {'session_id': 'current-session', 'is_current': true}, + ], + }); + client.enqueueJson({ + 'items': [ + {'session_id': 'old-session', 'is_current': false}, + ], + }); + + final current = await AuthProxyService.fetchCurrentSessionId(); + final missing = await AuthProxyService.fetchCurrentSessionId(); + + expect(current, 'current-session'); + expect(missing, isNull); + expect( + client.requests.first.headers['Authorization'], + 'Bearer jwt-token', + ); + }); + + test( + 'consent error는 code/message/details를 AuthProxyException으로 보존한다', + () async { + client.enqueueJson({ + 'code': 'tenant_not_allowed', + 'error': 'tenant blocked', + 'details': { + 'allowed_tenants': ['gp'], + }, + }, statusCode: 403); + + await expectLater( + AuthProxyService.getConsentInfo('consent-1'), + throwsA( + isA() + .having( + (error) => error.errorCode, + 'code', + 'tenant_not_allowed', + ) + .having((error) => error.message, 'message', 'tenant blocked') + .having( + (error) => error.details?['allowed_tenants'], + 'details', + ['gp'], + ), + ), + ); + }, + ); + + test( + 'approveQrLogin은 credential mode와 bearer token payload를 지원한다', + () async { + client.enqueueJson({'ok': true}); + + await AuthProxyService.approveQrLogin( + 'pending-qr', + token: 'jwt-token', + withCredentials: true, + ); + + expect(client.withCredentialsCalls, [true]); + expect(client.lastJsonBody, { + 'pendingRef': 'pending-qr', + 'token': 'jwt-token', + }); + }, + ); + + test('sendLog는 민감 정보를 제거한 client log를 전송한다', () async { + client.enqueueJson({'ok': true}); + + await AuthProxyService.sendLog( + 'warn', + 'token=secret password=hidden', + data: {'authorization': 'Bearer secret', 'safe': 'value'}, + ); + + expect(client.requests.single.url.path, '/api/v1/client-log'); + final body = client.lastJsonBody; + expect(body['level'], 'warn'); + expect(body['message'], isNot(contains('secret'))); + expect((body['data'] as Map)['safe'], 'value'); + expect( + (body['data'] as Map)['authorization'], + isNot(contains('secret')), + ); + }); + + test('주요 성공 API는 method, path, payload 계약을 유지한다', () async { + client + ..enqueueJson({'minLength': 12}) + ..enqueueJson({'id': 'user-1'}) + ..enqueueJson({'slug': 'tenant'}) + ..enqueueJson({'verified': true}) + ..enqueueJson({'verified': true}) + ..enqueueJson({'redirect_to': '/callback'}) + ..enqueueJson({'redirect_to': '/consent'}) + ..enqueueJson({'redirect_to': '/rejected'}) + ..enqueueJson({'redirect_to': '/oidc'}) + ..enqueueJson({'pendingRef': 'reset-pending'}) + ..enqueueJson({'ok': true}) + ..enqueueJson({'ok': true}) + ..enqueueJson({'verified': true}) + ..enqueueJson({'pendingRef': 'qr'}) + ..enqueueJson({'status': 'pending'}) + ..enqueueJson({'ok': true}) + ..enqueueJson({'ok': true}) + ..enqueueJson({ + 'users': [ + {'loginId': 'user@example.com'}, + ], + }) + ..enqueueJson({'ok': true}) + ..enqueueJson({'ok': true}) + ..enqueueJson({'ok': true}) + ..enqueueJson({ + 'items': [ + {'client_id': 'rp-1'}, + ], + }) + ..enqueueJson({'ok': true}) + ..enqueueJson({'available': true}) + ..enqueueJson({'available': true, 'message': 'ok'}) + ..enqueueJson({'message': 'duplicated'}, statusCode: 409) + ..enqueueAny([ + {'slug': 'gp'}, + ]) + ..enqueueJson({'ok': true}) + ..enqueueJson({'ok': true}) + ..enqueueJson({'verified': true}) + ..enqueueJson({'ok': true}); + + AuthTokenStore.setToken('jwt-token'); + + expect((await AuthProxyService.fetchPasswordPolicy())['minLength'], 12); + expect( + await AuthProxyService.getSessionStatus(token: 'session-token'), + 200, + ); + expect((await AuthProxyService.getTenantInfo())['slug'], 'tenant'); + expect( + (await AuthProxyService.verifyMagicLink( + 'magic-token', + verifyOnly: true, + ))['verified'], + isTrue, + ); + expect( + (await AuthProxyService.verifyLoginShortCode( + 'SHORT123', + verifyOnly: true, + ))['verified'], + isTrue, + ); + expect( + (await AuthProxyService.loginWithPassword( + 'user@example.com', + 'password', + loginChallenge: 'challenge-1', + ))['redirect_to'], + '/callback', + ); + expect( + (await AuthProxyService.acceptConsent( + 'consent-1', + grantScope: ['openid'], + ))['redirect_to'], + '/consent', + ); + expect( + (await AuthProxyService.rejectConsent('consent-1'))['redirect_to'], + '/rejected', + ); + expect( + (await AuthProxyService.acceptOidcLogin( + 'login-challenge', + token: 'jwt-token', + ))['redirect_to'], + '/oidc', + ); + expect( + (await AuthProxyService.initiatePasswordReset( + 'user@example.com', + drySend: true, + ))['pendingRef'], + 'reset-pending', + ); + expect( + (await AuthProxyService.completePasswordReset( + loginId: 'user@example.com', + token: 'reset-token', + newPassword: 'new-password', + ))['ok'], + isTrue, + ); + await AuthProxyService.sendSms('01012345678'); + expect( + (await AuthProxyService.verifySmsCode( + '01012345678', + '123456', + ))['verified'], + isTrue, + ); + expect((await AuthProxyService.initQrLogin())['pendingRef'], 'qr'); + expect((await AuthProxyService.pollQrStatus('qr'))['status'], 'pending'); + expect(await AuthProxyService.checkAdminAuth('admin-pass'), isTrue); + await AuthProxyService.createUser( + loginId: 'user@example.com', + adminPassword: 'admin-pass', + email: 'user@example.com', + phone: '01012345678', + displayName: 'User', + ); + expect(await AuthProxyService.listUsers('admin-pass', query: 'user'), [ + {'loginId': 'user@example.com'}, + ]); + await AuthProxyService.deleteUser('admin-pass', 'user@example.com'); + await AuthProxyService.updateUserStatus( + 'admin-pass', + 'user@example.com', + 'disabled', + ); + await AuthProxyService.updateUserDetails( + adminPassword: 'admin-pass', + loginId: 'user@example.com', + email: 'user2@example.com', + phone: '01099998888', + displayName: 'User 2', + ); + expect(await AuthProxyService.fetchLinkedRps(), [ + {'client_id': 'rp-1'}, + ]); + await AuthProxyService.revokeLinkedRp('rp-1'); + expect( + await AuthProxyService.checkEmailAvailability('new@example.com'), + isTrue, + ); + expect( + await AuthProxyService.checkLoginIDAvailability( + 'new@example.com', + tenantSlug: 'gp', + ), + {'available': true, 'message': 'ok'}, + ); + expect( + await AuthProxyService.checkLoginIDAvailability('dup@example.com'), + {'available': false, 'message': 'duplicated'}, + ); + expect( + await AuthProxyService.getActiveTenants(email: 'user@example.com'), + [ + {'slug': 'gp'}, + ], + ); + await AuthProxyService.sendSignupCode('user@example.com', 'email'); + await AuthProxyService.sendSignupCode('01012345678', 'sms'); + expect( + (await AuthProxyService.verifySignupCode( + 'user@example.com', + 'email', + '123456', + ))['verified'], + isTrue, + ); + await AuthProxyService.signup( + email: 'user@example.com', + password: 'password', + name: 'User', + phone: '01012345678', + affiliationType: 'tenant', + tenantSlug: 'gp', + department: 'R&D', + termsAccepted: true, + ); + + expect( + client.requests.map((request) => request.method), + containsAll(['GET', 'POST', 'PATCH', 'DELETE']), + ); + expect( + client.requests.map((request) => request.url.path), + containsAll([ + '/api/v1/auth/password/policy', + '/api/v1/auth/magic-link/verify', + '/api/v1/auth/login/code/verify-short', + '/api/v1/auth/password/login', + '/api/v1/auth/consent/accept', + '/api/v1/auth/consent/reject', + '/api/v1/auth/oidc/login/accept', + '/api/v1/auth/password/reset/initiate', + '/api/v1/auth/password/reset/complete', + '/api/v1/auth/sms', + '/api/v1/auth/verify-sms', + '/api/v1/auth/qr/init', + '/api/v1/auth/qr/poll', + '/api/v1/admin/check', + '/api/v1/admin/users', + '/api/v1/user/rp/linked', + '/api/v1/auth/signup/check-email', + '/api/v1/auth/signup/check-login-id', + '/api/v1/auth/signup/tenants', + '/api/v1/auth/signup/send-email-code', + '/api/v1/auth/signup/send-sms-code', + '/api/v1/auth/signup/verify-code', + '/api/v1/auth/signup', + ]), + ); + }); + + test('대표 실패 API는 예외와 fallback 값을 반환한다', () async { + client + ..enqueueJson({'error': 'bad policy'}, statusCode: 500) + ..enqueueJson({'error': 'profile failed'}, statusCode: 401) + ..enqueueJson({'error': 'tenant failed'}, statusCode: 500) + ..enqueueJson({'error': 'init failed'}, statusCode: 500) + ..enqueueJson({'error': 'poll failed'}, statusCode: 500) + ..enqueueJson({'error': 'magic failed'}, statusCode: 400) + ..enqueueJson({'error': 'code failed'}, statusCode: 400) + ..enqueueJson({'error': 'short failed'}, statusCode: 400) + ..enqueueJson({'error': 'password failed'}, statusCode: 401) + ..enqueueJson({'error': 'accept failed'}, statusCode: 400) + ..enqueueJson({'error': 'reject failed'}, statusCode: 400) + ..enqueueJson({'error': 'oidc failed'}, statusCode: 400) + ..enqueueJson({'error': 'reset init failed'}, statusCode: 400) + ..enqueueJson({'error': 'reset complete failed'}, statusCode: 400) + ..enqueueJson({'error': 'sms failed'}, statusCode: 400) + ..enqueueJson({'error': 'verify sms failed'}, statusCode: 400) + ..enqueueJson({'error': 'qr init failed'}, statusCode: 400) + ..enqueueJson({'error': 'qr poll failed'}, statusCode: 500) + ..enqueueJson({'error': 'qr approve failed'}, statusCode: 400) + ..enqueueJson({'error': 'create failed'}, statusCode: 400) + ..enqueueJson({'error': 'list failed'}, statusCode: 400) + ..enqueueJson({'error': 'delete failed'}, statusCode: 400) + ..enqueueJson({'error': 'status failed'}, statusCode: 400) + ..enqueueJson({'error': 'update failed'}, statusCode: 400) + ..enqueueJson({'error': 'linked failed'}, statusCode: 500) + ..enqueueJson({'error': 'revoke linked failed'}, statusCode: 400) + ..enqueueJson({'available': false}, statusCode: 500) + ..enqueueAny([], statusCode: 500) + ..enqueueJson({'error': 'signup code failed'}, statusCode: 400) + ..enqueueJson({'error': 'verify failed'}, statusCode: 400) + ..enqueueJson({'error': 'signup failed'}, statusCode: 400); + + Future expectThrows(Future Function() action) async { + await expectLater(action(), throwsA(isA())); + } + + await expectThrows(() async => AuthProxyService.fetchPasswordPolicy()); + await expectThrows(() async => AuthProxyService.getMe(token: 'bad')); + await expectThrows(() async => AuthProxyService.getTenantInfo()); + await expectThrows( + () async => AuthProxyService.initEnchantedLink('user'), + ); + await expectThrows( + () async => AuthProxyService.pollEnchantedLink('pending'), + ); + await expectThrows(() async => AuthProxyService.verifyMagicLink('magic')); + await expectThrows( + () async => AuthProxyService.verifyLoginCode('user', '123456'), + ); + await expectThrows( + () async => AuthProxyService.verifyLoginShortCode('SHORT'), + ); + await expectThrows( + () async => AuthProxyService.loginWithPassword('user', 'password'), + ); + await expectThrows(() async => AuthProxyService.acceptConsent('consent')); + await expectThrows(() async => AuthProxyService.rejectConsent('consent')); + await expectThrows(() async => AuthProxyService.acceptOidcLogin('login')); + await expectThrows( + () async => AuthProxyService.initiatePasswordReset('user'), + ); + await expectThrows( + () async => AuthProxyService.completePasswordReset(newPassword: 'new'), + ); + await expectThrows(() async => AuthProxyService.sendSms('01012345678')); + await expectThrows( + () async => AuthProxyService.verifySmsCode('01012345678', '123456'), + ); + await expectThrows(() async => AuthProxyService.initQrLogin()); + await expectThrows(() async => AuthProxyService.pollQrStatus('pending')); + await expectThrows( + () async => AuthProxyService.approveQrLogin('pending'), + ); + await expectThrows( + () async => AuthProxyService.createUser( + loginId: 'user', + adminPassword: 'admin', + ), + ); + await expectThrows(() async => AuthProxyService.listUsers('admin')); + await expectThrows( + () async => AuthProxyService.deleteUser('admin', 'user'), + ); + await expectThrows( + () async => + AuthProxyService.updateUserStatus('admin', 'user', 'disabled'), + ); + await expectThrows( + () async => AuthProxyService.updateUserDetails( + adminPassword: 'admin', + loginId: 'user', + ), + ); + await expectThrows(() async => AuthProxyService.fetchLinkedRps()); + await expectThrows(() async => AuthProxyService.revokeLinkedRp('rp')); + expect( + await AuthProxyService.checkEmailAvailability('used@example.com'), + isFalse, + ); + expect(await AuthProxyService.getActiveTenants(), isEmpty); + await expectThrows( + () async => + AuthProxyService.sendSignupCode('user@example.com', 'email'), + ); + await expectThrows( + () async => AuthProxyService.verifySignupCode( + 'user@example.com', + 'email', + '123456', + ), + ); + await expectThrows( + () async => AuthProxyService.signup( + email: 'user@example.com', + password: 'password', + name: 'User', + phone: '01012345678', + affiliationType: 'tenant', + department: 'R&D', + termsAccepted: true, + ), + ); + }); + }); +} + +class _QueuedResponse { + const _QueuedResponse(this.statusCode, this.body); + + final int statusCode; + final String body; +} + +class _RecordingClient extends http.BaseClient { + final requests = []; + final withCredentialsCalls = []; + final _responses = <_QueuedResponse>[]; + int closedCount = 0; + + void enqueueJson(Map body, {int statusCode = 200}) { + enqueueAny(body, statusCode: statusCode); + } + + void enqueueAny(Object? body, {int statusCode = 200}) { + _responses.add(_QueuedResponse(statusCode, jsonEncode(body))); + } + + Map get lastJsonBody { + final request = requests.last as http.Request; + return jsonDecode(request.body) as Map; + } + + @override + Future send(http.BaseRequest request) async { + requests.add(request); + final response = _responses.isEmpty + ? const _QueuedResponse(200, '{}') + : _responses.removeAt(0); + return http.StreamedResponse( + Stream.value(utf8.encode(response.body)), + response.statusCode, + request: request, + headers: {'content-type': 'application/json'}, + ); + } + + @override + void close() { + closedCount += 1; + super.close(); + } +} diff --git a/userfront/test/auth_token_store_backend_test.dart b/userfront/test/auth_token_store_backend_test.dart index 97d2278a..12814781 100644 --- a/userfront/test/auth_token_store_backend_test.dart +++ b/userfront/test/auth_token_store_backend_test.dart @@ -30,6 +30,54 @@ void main() { expect(session.read('baron_auth_provider'), 'ory'); }); + test('cookie mode는 token을 제거하고 provider를 유지한다', () { + final local = _FakeTarget(); + final session = _FakeTarget(); + final store = AuthTokenStoreBackend( + localTarget: local, + sessionTarget: session, + ); + + store.setToken('jwt-token', provider: 'ory'); + store.setCookieMode(provider: 'cookie-provider'); + + expect(store.getToken(), isNull); + expect(store.usesCookie(), isTrue); + expect(store.getProvider(), 'cookie-provider'); + expect(local.read('baron_auth_cookie_mode'), '1'); + expect(session.read('baron_auth_token'), isNull); + }); + + test('pending provider는 빈 값이면 제거하고 저장소 오류는 건너뛴다', () { + final local = _FakeTarget(throwsOnWrite: true, throwsOnRemove: true); + final session = _FakeTarget(); + final store = AuthTokenStoreBackend( + localTarget: local, + sessionTarget: session, + ); + + store.setPendingProvider('ory'); + expect(store.getPendingProvider(), 'ory'); + expect(session.read('baron_auth_pending_provider'), 'ory'); + + store.setPendingProvider(''); + expect(store.getPendingProvider(), isNull); + expect(session.read('baron_auth_pending_provider'), isNull); + }); + + test('local/session 저장이 모두 실패해도 memory fallback으로 읽을 수 있다', () { + final store = AuthTokenStoreBackend( + localTarget: _FakeTarget(throwsOnWrite: true, throwsOnRead: true), + sessionTarget: _FakeTarget(throwsOnWrite: true, throwsOnRead: true), + ); + + store.setToken('memory-token'); + store.setPendingProvider('memory-provider'); + + expect(store.getToken(), 'memory-token'); + expect(store.getPendingProvider(), 'memory-provider'); + }); + test('clear 호출 시 local/session/memory 모두 정리된다', () { final local = _FakeTarget( readSeed: { @@ -67,11 +115,13 @@ class _FakeTarget implements AuthTokenStorageTarget { _FakeTarget({ this.throwsOnRead = false, this.throwsOnWrite = false, + this.throwsOnRemove = false, Map? readSeed, }) : _data = {...?readSeed}; final bool throwsOnRead; final bool throwsOnWrite; + final bool throwsOnRemove; final Map _data; @override @@ -84,6 +134,9 @@ class _FakeTarget implements AuthTokenStorageTarget { @override void remove(String key) { + if (throwsOnRemove) { + throw Exception('remove failed'); + } _data.remove(key); } diff --git a/userfront/test/auth_token_store_test.dart b/userfront/test/auth_token_store_test.dart new file mode 100644 index 00000000..7b1ca8c1 --- /dev/null +++ b/userfront/test/auth_token_store_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/auth_token_store.dart'; + +void main() { + group('AuthTokenStore facade', () { + setUp(AuthTokenStore.clear); + tearDown(AuthTokenStore.clear); + + test('token, provider, cookie mode, pending provider 상태를 위임한다', () { + expect(AuthTokenStore.hasToken(), isFalse); + + AuthTokenStore.setToken('jwt-token', provider: 'ory'); + + expect(AuthTokenStore.hasToken(), isTrue); + expect(AuthTokenStore.getToken(), 'jwt-token'); + expect(AuthTokenStore.getProvider(), 'ory'); + expect(AuthTokenStore.usesCookie(), isFalse); + + AuthTokenStore.setPendingProvider('pending-ory'); + expect(AuthTokenStore.getPendingProvider(), 'pending-ory'); + AuthTokenStore.clearPendingProvider(); + expect(AuthTokenStore.getPendingProvider(), isNull); + + AuthTokenStore.setCookieMode(provider: 'cookie-ory'); + expect(AuthTokenStore.hasToken(), isFalse); + expect(AuthTokenStore.getToken(), isNull); + expect(AuthTokenStore.getProvider(), 'cookie-ory'); + expect(AuthTokenStore.usesCookie(), isTrue); + + AuthTokenStore.clear(); + expect(AuthTokenStore.getProvider(), isNull); + expect(AuthTokenStore.usesCookie(), isFalse); + }); + }); +} diff --git a/userfront/test/login_challenge_resolver_test.dart b/userfront/test/login_challenge_resolver_test.dart index b379af80..0732cfe0 100644 --- a/userfront/test/login_challenge_resolver_test.dart +++ b/userfront/test/login_challenge_resolver_test.dart @@ -54,6 +54,37 @@ void main() { expect(resolved.rawHrefHasLoginChallenge, isTrue); }); + test('raw query 파싱 실패 시 수동 파싱으로 복구하고 diagnostics를 남긴다', () { + final resolved = resolveLoginChallenge( + widgetLoginChallenge: ' ', + uri: Uri.parse('/ko/login'), + rawSearch: '?x=%E0%A4%A&login_challenge=manual%20value', + rawHref: '', + ); + + expect(resolved.value, 'manual value'); + expect(resolved.source, LoginChallengeSource.rawSearch); + expect(resolved.toDiagnostics(), { + 'resolved_value_len': 12, + 'resolved_source': 'rawSearch', + 'uri_has_login_challenge': false, + 'raw_search_has_login_challenge': true, + 'raw_href_has_login_challenge': false, + }); + }); + + test('raw href가 일반 URI로 파싱되지 않아도 query 조각에서 복구한다', () { + final resolved = resolveLoginChallenge( + widgetLoginChallenge: null, + uri: Uri.parse('/ko/login'), + rawSearch: '?login_challenge', + rawHref: 'not a url ?login_challenge=href%20fallback#fragment', + ); + + expect(resolved.value, 'href fallback'); + expect(resolved.source, LoginChallengeSource.rawHref); + }); + test('값이 전부 없으면 missing', () { final resolved = resolveLoginChallenge( widgetLoginChallenge: null, diff --git a/userfront/test/login_link_route_policy_test.dart b/userfront/test/login_link_route_policy_test.dart index d81c8aff..1036089c 100644 --- a/userfront/test/login_link_route_policy_test.dart +++ b/userfront/test/login_link_route_policy_test.dart @@ -29,5 +29,47 @@ void main() { ); expect(isPublic, isTrue); }); + + test('public auth path 목록을 허용하고 일반 보호 경로는 제외한다', () { + final publicPaths = [ + '/signin', + '/signup', + '/login', + '/registration', + '/verify', + '/verification', + '/verify-complete', + '/verify/token', + '/l/AB123456', + '/approve', + '/ql/AB123456', + '/forgot-password', + '/recovery', + '/reset-password', + '/error', + '/settings', + '/consent', + '/consent/challenge', + ]; + + for (final path in publicPaths) { + expect(isPublicAuthPath(path, Uri.parse(path)), isTrue, reason: path); + } + + expect( + isPublicAuthPath('/dashboard', Uri.parse('/ko/dashboard')), + isFalse, + ); + expect( + isPublicAuthPath('/dashboard', Uri.parse('/ko/auth/consent/callback')), + isTrue, + ); + }); + + test('short code route가 아니면 null을 반환한다', () { + expect(extractLoginShortCode(Uri.parse('/login')), isNull); + expect(extractLoginShortCode(Uri.parse('/l')), isNull); + expect(extractLoginShortCode(Uri.parse('/x/AB123456')), isNull); + }); }); } diff --git a/userfront/test/logout_service_test.dart b/userfront/test/logout_service_test.dart index b9cdc5ec..36bd343a 100644 --- a/userfront/test/logout_service_test.dart +++ b/userfront/test/logout_service_test.dart @@ -48,6 +48,52 @@ void main() { expect(events, ['load', 'clear', 'notify']); }); + test('현재 세션 ID가 빈 문자열이면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return ''; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'clear', 'notify']); + }); + + test('현재 세션 조회가 실패해도 로컬 로그아웃은 계속 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + throw Exception('load failed'); + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'clear', 'notify']); + }); + test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async { final events = []; final service = LogoutService( diff --git a/userfront/test/runtime_env_compile_time_test.dart b/userfront/test/runtime_env_compile_time_test.dart index ecfeba3a..89057305 100644 --- a/userfront/test/runtime_env_compile_time_test.dart +++ b/userfront/test/runtime_env_compile_time_test.dart @@ -42,5 +42,15 @@ void main() { expect(runtimeBackendUrl(), isNot(endsWith('/'))); expect(runtimeUserfrontUrl(), isNot(endsWith('/'))); }); + + test( + 'sanitizedUrl removes dollar signs, whitespace, and trailing slash', + () { + expect( + sanitizedUrl(' https://example.test/path/\$ '), + 'https://example.test/path', + ); + }, + ); }); } diff --git a/userfront/test/verification_route_policy_test.dart b/userfront/test/verification_route_policy_test.dart index d1d9b995..cd33eb9b 100644 --- a/userfront/test/verification_route_policy_test.dart +++ b/userfront/test/verification_route_policy_test.dart @@ -43,6 +43,36 @@ void main() { ); }); + test('전용 verify 계열 라우트와 완료 라우트 경로를 식별한다', () { + expect( + buildLocalizedVerificationCompletePath('ko'), + '/ko/verify-complete', + ); + expect(isDedicatedVerificationRoute(Uri.parse('/verification')), isTrue); + expect(isDedicatedVerificationRoute(Uri.parse('/ko/verify/abc')), isTrue); + expect(isDedicatedVerificationRoute(Uri.parse('/en/l/SHORT123')), isTrue); + expect(isDedicatedVerificationRoute(Uri.parse('/ko/signin')), isFalse); + }); + + test('빈 pendingRef와 불완전한 payload는 리다이렉트 query에서 제외한다', () { + expect( + buildDedicatedVerificationRedirect( + Uri.parse( + '/signin?loginId=user%40example.com&code=123456&pendingRef=', + ), + localeCode: 'en', + ), + '/en/verify?loginId=user%40example.com&code=123456', + ); + expect( + buildDedicatedVerificationRedirect( + Uri.parse('/signin?loginId=user%40example.com'), + localeCode: 'en', + ), + isNull, + ); + }); + test('이미 전용 verify 라우트면 다시 리다이렉트하지 않는다', () { final redirect = buildDedicatedVerificationRedirect( Uri.parse('/ko/verify?loginId=e2e%40example.com&code=654321'),