forked from baron/baron-sso
userfront&backend test coverage 추가
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
93
backend/internal/domain/json_map_test.go
Normal file
93
backend/internal/domain/json_map_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
357
backend/internal/domain/model_hooks_test.go
Normal file
357
backend/internal/domain/model_hooks_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
80
backend/internal/domain/shared_link_test.go
Normal file
80
backend/internal/domain/shared_link_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -251,8 +251,8 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
|
||||
user,
|
||||
gpdtdcTenant,
|
||||
map[string]domain.Tenant{
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
firstTenantID: firstTenant,
|
||||
gpdtdcID: gpdtdcTenant,
|
||||
firstTenantID: firstTenant,
|
||||
secondTenantID: secondTenant,
|
||||
},
|
||||
nil,
|
||||
|
||||
27
backend/internal/utils/audit_test.go
Normal file
27
backend/internal/utils/audit_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
115
scripts/summarize_flutter_coverage.mjs
Normal file
115
scripts/summarize_flutter_coverage.mjs
Normal file
@@ -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));
|
||||
@@ -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 [
|
||||
|
||||
@@ -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"
|
||||
|
||||
65
test/summarize_userfront_coverage_test.sh
Normal file
65
test/summarize_userfront_coverage_test.sh
Normal file
@@ -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"
|
||||
@@ -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");
|
||||
|
||||
@@ -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<http.Response> _get(
|
||||
Uri url, {
|
||||
Map<String, String>? headers,
|
||||
bool withCredentials = false,
|
||||
}) async {
|
||||
final client = _createClient(withCredentials: withCredentials);
|
||||
try {
|
||||
return await client.get(url, headers: headers);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<http.Response> _post(
|
||||
Uri url, {
|
||||
Map<String, String>? 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<http.Response> _patch(
|
||||
Uri url, {
|
||||
Map<String, String>? 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<http.Response> _delete(
|
||||
Uri url, {
|
||||
Map<String, String>? headers,
|
||||
bool withCredentials = false,
|
||||
}) async {
|
||||
final client = _createClient(withCredentials: withCredentials);
|
||||
try {
|
||||
return await client.delete(url, headers: headers);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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 = <String, String>{'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 = <String, String>{'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<int> 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 = <String, String>{'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 = <String, String>{'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<Map<String, dynamic>> 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 = <String, String>{'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 = <String, String>{'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 = <String, String>{'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 = <String, String>{'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<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
for (final item in items.whereType<Map<String, dynamic>>()) {
|
||||
if (item['is_current'] == true) {
|
||||
final sessionId = item['session_id']?.toString().trim() ?? '';
|
||||
if (sessionId.isNotEmpty) {
|
||||
return sessionId;
|
||||
}
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? const [];
|
||||
for (final item in items.whereType<Map<String, dynamic>>()) {
|
||||
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<Map<String, dynamic>> 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<String, dynamic> ? 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<String, dynamic> ? 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 = <String, dynamic>{'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<void> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<bool> 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 = <String, String>{'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 = <String, String>{'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<bool> 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<dynamic> list = jsonDecode(response.body);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
@@ -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({
|
||||
|
||||
589
userfront/test/auth_proxy_service_test.dart
Normal file
589
userfront/test/auth_proxy_service_test.dart
Normal file
@@ -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<String>());
|
||||
});
|
||||
|
||||
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<AuthProxyException>()
|
||||
.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<String, dynamic>)['safe'], 'value');
|
||||
expect(
|
||||
(body['data'] as Map<String, dynamic>)['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<void> expectThrows(Future<void> Function() action) async {
|
||||
await expectLater(action(), throwsA(isA<Object>()));
|
||||
}
|
||||
|
||||
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 = <http.BaseRequest>[];
|
||||
final withCredentialsCalls = <bool>[];
|
||||
final _responses = <_QueuedResponse>[];
|
||||
int closedCount = 0;
|
||||
|
||||
void enqueueJson(Map<String, dynamic> body, {int statusCode = 200}) {
|
||||
enqueueAny(body, statusCode: statusCode);
|
||||
}
|
||||
|
||||
void enqueueAny(Object? body, {int statusCode = 200}) {
|
||||
_responses.add(_QueuedResponse(statusCode, jsonEncode(body)));
|
||||
}
|
||||
|
||||
Map<String, dynamic> get lastJsonBody {
|
||||
final request = requests.last as http.Request;
|
||||
return jsonDecode(request.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>? readSeed,
|
||||
}) : _data = {...?readSeed};
|
||||
|
||||
final bool throwsOnRead;
|
||||
final bool throwsOnWrite;
|
||||
final bool throwsOnRemove;
|
||||
final Map<String, String> _data;
|
||||
|
||||
@override
|
||||
@@ -84,6 +134,9 @@ class _FakeTarget implements AuthTokenStorageTarget {
|
||||
|
||||
@override
|
||||
void remove(String key) {
|
||||
if (throwsOnRemove) {
|
||||
throw Exception('remove failed');
|
||||
}
|
||||
_data.remove(key);
|
||||
}
|
||||
|
||||
|
||||
35
userfront/test/auth_token_store_test.dart
Normal file
35
userfront/test/auth_token_store_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,52 @@ void main() {
|
||||
expect(events, ['load', 'clear', 'notify']);
|
||||
});
|
||||
|
||||
test('현재 세션 ID가 빈 문자열이면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async {
|
||||
final events = <String>[];
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
final service = LogoutService(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user