이코에코(Eco²)/Eventual Consistency
이코에코(Eco²) Eventual Consistency #1: ext-authz Blacklist 로컬 캐시 및 Fanout 구현
mango_fr
2025. 12. 30. 11:48

이전 글: ext-authz 로컬 캐싱 설계
개요
설계 문서에서 제안한 로컬 캐시 + MQ 브로드캐스트 아키텍처를 구현하고 검증한 과정을 기록합니다.
1. 구현 결과 요약
1.1 메트릭 검증
ext_authz_blacklist_cache_size 7 ← 캐시에 7개 유지
ext_authz_blacklist_cache_evictions_total 0 ← eviction 없음
ext_authz_mq_events_received_total 1 ← MQ 이벤트 수신
ext_authz_mq_events_processed_total 1 ← 처리 성공
1.2 동작 흐름
┌─────────────────────────────────────────────────────────────────────────┐
│ 검증된 E2E 흐름 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ [1] Bootstrap (Pod 시작) │
│ ext-authz ──SCAN──▶ Redis ──7 entries──▶ Local Cache │
│ │
│ [2] 로그아웃 이벤트 전파 │
│ auth-api ──logout──▶ Redis (SETEX) │
│ │ │
│ └──publish──▶ RabbitMQ (blacklist.events) │
│ │ │
│ ├──▶ ext-authz Pod 1: cache.Add() │
│ └──▶ ext-authz Pod 2: cache.Add() │
│ │
│ [3] 인증 요청 처리 │
│ Client ──▶ ext-authz ──O(1)──▶ Local Cache │
│ │ │ │
│ │ └── Hit: 401 Forbidden │
│ │ └── Miss: Allow │
│ │ │
│ └── Redis 호출 없음 (100% 로컬) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2. ext-authz 구현 (Go)
2.1 프로젝트 구조
domains/ext-authz/
├── internal/
│ ├── cache/
│ │ ├── blacklist.go # sync.Map 기반 로컬 캐시
│ │ ├── blacklist_test.go # 단위 테스트
│ │ └── bootstrap.go # Redis 초기 로드
│ ├── mq/
│ │ └── consumer.go # RabbitMQ Fanout Consumer
│ ├── config/
│ │ └── config.go # 환경변수 (LOCAL_CACHE_ENABLED, AMQP_URL)
│ └── server/
│ └── server.go # gRPC Check 메서드 통합
└── main.go # 초기화 및 Graceful Shutdown
2.2 BlacklistCache 구현
// internal/cache/blacklist.go
package cache
import (
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Prometheus 메트릭
var (
cacheSize = promauto.NewGauge(prometheus.GaugeOpts{
Name: "ext_authz_blacklist_cache_size",
Help: "Current number of entries in the blacklist cache",
})
cacheHits = promauto.NewCounter(prometheus.CounterOpts{
Name: "ext_authz_blacklist_cache_hits_total",
Help: "Total number of cache hits",
})
cacheMisses = promauto.NewCounter(prometheus.CounterOpts{
Name: "ext_authz_blacklist_cache_misses_total",
Help: "Total number of cache misses",
})
cacheAdditions = promauto.NewCounter(prometheus.CounterOpts{
Name: "ext_authz_blacklist_cache_additions_total",
Help: "Total number of entries added to cache",
})
cacheEvictions = promauto.NewCounter(prometheus.CounterOpts{
Name: "ext_authz_blacklist_cache_evictions_total",
Help: "Total number of expired entries evicted",
})
)
// BlacklistCache는 JTI → 만료시간 매핑을 저장하는 스레드 안전 캐시
type BlacklistCache struct {
items *sync.Map // map[string]time.Time
cleanupInterval time.Duration
done chan struct{}
}
// NewBlacklistCache는 주기적 정리 goroutine과 함께 캐시 생성
func NewBlacklistCache(cleanupInterval time.Duration) *BlacklistCache {
c := &BlacklistCache{
items: &sync.Map{},
cleanupInterval: cleanupInterval,
done: make(chan struct{}),
}
go c.cleanupLoop()
return c
}
// IsBlacklisted는 O(1) 로컬 조회 (Redis 호출 없음)
func (c *BlacklistCache) IsBlacklisted(jti string) bool {
val, ok := c.items.Load(jti)
if !ok {
cacheMisses.Inc()
return false
}
expireAt := val.(time.Time)
if time.Now().After(expireAt) {
// Lazy deletion: 만료된 항목 제거
c.items.Delete(jti)
cacheEvictions.Inc()
c.updateSizeMetric()
cacheMisses.Inc()
return false
}
cacheHits.Inc()
return true
}
// Add는 MQ 이벤트 수신 시 호출
func (c *BlacklistCache) Add(jti string, expireAt time.Time) {
c.items.Store(jti, expireAt)
cacheAdditions.Inc()
c.updateSizeMetric()
}
// cleanupLoop는 주기적으로 만료된 항목 정리
func (c *BlacklistCache) cleanupLoop() {
ticker := time.NewTicker(c.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-c.done:
return
case <-ticker.C:
c.cleanup()
}
}
}
func (c *BlacklistCache) cleanup() {
now := time.Now()
c.items.Range(func(key, value any) bool {
if now.After(value.(time.Time)) {
c.items.Delete(key)
cacheEvictions.Inc()
}
return true
})
c.updateSizeMetric()
}
핵심 설계 결정:
| 결정 | 이유 |
|---|---|
sync.Map 사용 |
Go 표준 라이브러리, 읽기 최적화, GC 친화적 |
| Lazy Deletion | 조회 시 만료 체크로 메모리 효율 + 정확성 |
| Background Cleanup | 메모리 누수 방지, 만료 항목 주기적 정리 |
| Prometheus 메트릭 | 운영 가시성, 캐시 효율 모니터링 |
2.3 Bootstrap 구현
// internal/cache/bootstrap.go
package cache
import (
"context"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
const (
blacklistPrefix = "blacklist:"
scanBatchSize = 1000
)
// BootstrapFromRedis는 Pod 시작 시 Redis에서 전체 블랙리스트 로드
func BootstrapFromRedis(
ctx context.Context,
client *redis.Client,
cache *BlacklistCache,
) (int, error) {
var cursor uint64
loaded := 0
for {
// SCAN으로 배치 조회 (KEYS 대신 - 프로덕션 안전)
keys, nextCursor, err := client.Scan(
ctx, cursor, blacklistPrefix+"*", scanBatchSize,
).Result()
if err != nil {
return loaded, err
}
for _, key := range keys {
// TTL 조회
ttl, err := client.TTL(ctx, key).Result()
if err != nil || ttl <= 0 {
continue // 만료됨 또는 에러
}
jti := strings.TrimPrefix(key, blacklistPrefix)
expireAt := time.Now().Add(ttl)
cache.Add(jti, expireAt)
loaded++
}
cursor = nextCursor
if cursor == 0 {
break
}
}
return loaded, nil
}
Bootstrap 시퀀스:
┌─────────────────────────────────────────────────────────────────┐
│ Pod 시작 시 Bootstrap │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ext-authz Pod Redis │
│ │ │ │
│ │──── SCAN blacklist:* ─────────────▶│ │
│ │◀─── [key1, key2, ...] ─────────────│ │
│ │ │ │
│ │──── TTL key1 ──────────────────────▶│ │
│ │◀─── 3600 (seconds) ────────────────│ │
│ │ │ │
│ │ cache.Add(jti1, now+3600s) │ │
│ │ │ │
│ │──── SCAN (cursor) ─────────────────▶│ │
│ │◀─── [key3, key4, ...] ─────────────│ │
│ │ ... │ │
│ │ │ │
│ │ ✅ Bootstrap 완료 (7 entries) │ │
│ │ │ │
└─────────────────────────────────────────────────────────────────┘
2.4 MQ Consumer 구현
// internal/mq/consumer.go
package mq
import (
"encoding/json"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
mqEventsReceived = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ext_authz_mq_events_received_total",
Help: "Total number of events received from RabbitMQ",
}, []string{"type"})
mqEventsProcessed = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ext_authz_mq_events_processed_total",
Help: "Total number of events successfully processed",
}, []string{"type"})
)
const exchangeName = "blacklist.events"
// BlacklistEvent는 auth-api에서 발행하는 이벤트 구조
type BlacklistEvent struct {
Type string `json:"type"` // "add"
JTI string `json:"jti"` // JWT ID
ExpireAt time.Time `json:"expires_at"` // 만료 시간
}
type BlacklistConsumer struct {
amqpURL string
cache BlacklistCache
done chan struct{}
}
func (c *BlacklistConsumer) connect() error {
conn, err := amqp.Dial(c.amqpURL)
if err != nil {
return err
}
ch, err := conn.Channel()
if err != nil {
return err
}
// Fanout Exchange 선언 (이미 존재하면 무시)
err = ch.ExchangeDeclare(
exchangeName, // name
"fanout", // type
true, // durable
false, // auto-deleted
false, // internal
false, // no-wait
nil, // arguments
)
if err != nil {
return err
}
// Anonymous Exclusive Queue (Pod별 고유)
q, err := ch.QueueDeclare(
"", // name (auto-generated: amq.gen-xxx)
false, // durable
true, // delete when unused
true, // exclusive
false, // no-wait
nil, // arguments
)
if err != nil {
return err
}
// Queue를 Exchange에 바인딩
err = ch.QueueBind(
q.Name, // queue name
"", // routing key (fanout은 무시)
exchangeName, // exchange
false,
nil,
)
if err != nil {
return err
}
// 메시지 소비 시작
msgs, err := ch.Consume(
q.Name, // queue
"", // consumer tag
true, // auto-ack
true, // exclusive
false, // no-local
false, // no-wait
nil, // arguments
)
if err != nil {
return err
}
// 메시지 처리 루프
for msg := range msgs {
c.handleMessage(msg.Body)
}
return nil
}
func (c *BlacklistConsumer) handleMessage(body []byte) {
var event BlacklistEvent
if err := json.Unmarshal(body, &event); err != nil {
return
}
mqEventsReceived.WithLabelValues(event.Type).Inc()
switch event.Type {
case "add":
c.cache.Add(event.JTI, event.ExpireAt)
mqEventsProcessed.WithLabelValues("add").Inc()
}
}
Fanout Exchange 동작:
┌─────────────────────────────────────────────────────────────────────────┐
│ RabbitMQ Fanout Exchange │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ blacklist.events (fanout) │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ amq.gen-xxx │ │ amq.gen-yyy │ │ amq.gen-zzz │ │
│ │ (Pod 1 Queue) │ │ (Pod 2 Queue) │ │ (Pod 3 Queue) │ │
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ ext-authz │ │ ext-authz │ │ ext-authz │ │
│ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │
│ │ cache.Add() │ │ cache.Add() │ │ cache.Add() │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
│ │
│ ✅ 모든 Pod가 동일한 이벤트 수신 (Broadcast) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
3. auth-api 구현 (Python)
3.1 Publisher 구현
# domains/auth/services/blacklist_publisher.py
import json
import logging
from datetime import datetime
from typing import Optional
import pika
from domains.auth.core.config import get_settings
logger = logging.getLogger(__name__)
_publisher: Optional["BlacklistEventPublisher"] = None
def get_blacklist_publisher() -> Optional["BlacklistEventPublisher"]:
"""싱글톤 Publisher 인스턴스 반환"""
global _publisher
if _publisher is None:
settings = get_settings()
if settings.amqp_url:
try:
_publisher = BlacklistEventPublisher(settings.amqp_url)
logger.info("Blacklist event publisher initialized")
except Exception as e:
logger.warning(f"Failed to initialize publisher: {e}")
return None
return _publisher
class BlacklistEventPublisher:
"""RabbitMQ Fanout Exchange로 블랙리스트 이벤트 발행"""
EXCHANGE_NAME = "blacklist.events"
EXCHANGE_TYPE = "fanout"
def __init__(self, amqp_url: str):
self.amqp_url = amqp_url
self._connection = None
self._channel = None
def _ensure_connection(self):
"""Lazy 연결 생성"""
if self._connection is None or self._connection.is_closed:
params = pika.URLParameters(self.amqp_url)
self._connection = pika.BlockingConnection(params)
self._channel = self._connection.channel()
self._channel.exchange_declare(
exchange=self.EXCHANGE_NAME,
exchange_type=self.EXCHANGE_TYPE,
durable=True,
)
def publish_add(self, jti: str, expires_at: datetime) -> bool:
"""블랙리스트 추가 이벤트 발행"""
try:
self._ensure_connection()
event = {
"type": "add",
"jti": jti,
"expires_at": expires_at.isoformat(),
}
self._channel.basic_publish(
exchange=self.EXCHANGE_NAME,
routing_key="", # Fanout은 routing key 무시
body=json.dumps(event),
properties=pika.BasicProperties(
content_type="application/json",
delivery_mode=2, # Persistent
),
)
return True
except Exception as e:
logger.error(f"Failed to publish: {e}")
self._connection = None
self._channel = None
return False
3.2 TokenBlacklist 통합
# domains/auth/services/token_blacklist.py
from datetime import datetime
from redis.asyncio import Redis
from domains.auth.services.blacklist_publisher import get_blacklist_publisher
class TokenBlacklist:
def __init__(self, redis: Redis):
self.redis = redis
async def add(self, payload: TokenPayload, reason: str = "logout") -> None:
expires_at = datetime.fromtimestamp(payload.exp, tz=timezone.utc)
ttl = compute_ttl_seconds(expires_at)
if ttl <= 0:
return
# 1. Redis에 저장 (Primary Source of Truth)
data = {
"user_id": payload.sub,
"reason": reason,
"blacklisted_at": now_utc().isoformat(),
"expires_at": expires_at.isoformat(),
}
await self.redis.setex(
f"blacklist:{payload.jti}",
ttl,
json.dumps(data),
)
# 2. MQ 이벤트 발행 (ext-authz 로컬 캐시 동기화)
publisher = get_blacklist_publisher()
if publisher:
publisher.publish_add(payload.jti, expires_at)
이중 쓰기 패턴:
┌─────────────────────────────────────────────────────────────────────────┐
│ Logout 처리 흐름 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ auth-api │
│ │ │
│ │ [1] Redis SETEX (Primary) │
│ │──────────────────────────────────▶ Redis │
│ │ blacklist:{jti} = {...} │
│ │ TTL = token.exp - now │
│ │ │
│ │ [2] MQ Publish (Broadcast) │
│ │──────────────────────────────────▶ RabbitMQ │
│ │ blacklist.events │
│ │ {type: "add", jti, expires_at} │
│ │ │
│ │ [3] Response │
│ │◀───────────────────────────────── 200 OK │
│ │
└─────────────────────────────────────────────────────────────────────────┘
4. Kubernetes 매니페스트
4.1 RabbitMQ Exchange 정의
# workloads/rabbitmq/base/topology/exchanges.yaml
apiVersion: rabbitmq.com/v1beta1
kind: Exchange
metadata:
name: blacklist-events
namespace: rabbitmq
spec:
name: blacklist.events
type: fanout
durable: true
autoDelete: false
vhost: eco2
rabbitmqClusterReference:
name: eco2-rabbitmq
namespace: rabbitmq
4.2 ext-authz Deployment 환경변수
# workloads/domains/ext-authz/base/deployment.yaml
env:
# 로컬 캐시 활성화
- name: LOCAL_CACHE_ENABLED
value: "true"
- name: LOCAL_CACHE_CLEANUP_INTERVAL
value: "60"
# RabbitMQ 연결
- name: AMQP_URL
valueFrom:
secretKeyRef:
name: auth-secret
key: AUTH_AMQP_URL
4.3 NetworkPolicy
# workloads/network-policies/base/allow-rabbitmq-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-rabbitmq-egress
namespace: auth
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: rabbitmq
ports:
- protocol: TCP
port: 5672
5. 트러블슈팅
5.1 필드명 불일치 버그
증상:
ext_authz_blacklist_cache_additions_total 5
ext_authz_blacklist_cache_evictions_total 5 ← 즉시 eviction!
ext_authz_blacklist_cache_size 0
원인:
auth-api (Python): "expires_at" (s 있음)
ext-authz (Go): "expire_at" (s 없음) ← 불일치!
Go에서 파싱 시 ExpireAt이 zero value (0001-01-01)가 되어 즉시 만료 처리
수정:
// Before
ExpireAt time.Time `json:"expire_at"`
// After
ExpireAt time.Time `json:"expires_at"`
5.2 pika 연결 타임아웃
증상:
Failed to publish blacklist event: Transport indicated EOF
원인: pika BlockingConnection이 idle 상태에서 연결 끊김
대응: 에러 발생 시 연결 리셋 후 재시도
except Exception as e:
self._connection = None
self._channel = None
return False
향후 개선: heartbeat 설정 또는 connection recovery 구현
6. 검증 결과
6.1 Prometheus 메트릭
kubectl port-forward -n auth svc/ext-authz 9090:9090 &
curl -s http://localhost:9090/metrics | grep -E 'cache|mq_events'
ext_authz_blacklist_cache_size 7 ✅
ext_authz_blacklist_cache_additions_total 7
ext_authz_blacklist_cache_evictions_total 0 ✅
ext_authz_blacklist_cache_hits_total 0
ext_authz_blacklist_cache_misses_total 3
ext_authz_mq_events_received_total{type="add"} 1 ✅
ext_authz_mq_events_processed_total{type="add"} 1 ✅
ext_authz_mq_events_failed_total 0
6.2 E2E 검증 시퀀스
┌─────────────────────────────────────────────────────────────────────────┐
│ E2E 검증 결과 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ [1] Pod 시작 → Bootstrap │
│ ✅ Redis SCAN → 7 entries 로드 │
│ ✅ cache_size = 7 │
│ │
│ [2] 로그인 → 로그아웃 │
│ ✅ auth-api: Redis SETEX 성공 │
│ ✅ auth-api: MQ publish 성공 (에러 로그 없음) │
│ ✅ ext-authz: MQ 이벤트 수신 (mq_events_received = 1) │
│ ✅ ext-authz: cache.Add() 성공 (mq_events_processed = 1) │
│ │
│ [3] 캐시 상태 │
│ ✅ cache_size = 7 (eviction 없음) │
│ ✅ 새 항목 정상 유지 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
7. 테스트
7.1 Go 테스트 커버리지
$ go test ./... -cover
ok .../internal/jwt coverage: 98.0% of statements ← ✅
ok .../internal/cache coverage: 66.2% of statements
ok .../internal/store coverage: 50.0% of statements
ok .../internal/mq coverage: 32.8% of statements
패키지별 커버리지:
| 패키지 | 커버리지 | 설명 |
|---|---|---|
jwt |
98.0% | JWT 검증, 클레임 파싱, 스코프 체크 |
cache |
66.2% | 블랙리스트 캐시, TTL 관리 |
store |
50.0% | Redis 클라이언트 래퍼 |
mq |
32.8% | RabbitMQ 컨슈머 (connect는 통합 테스트 필요) |
핵심 함수 커버리지:
| 함수 | 커버리지 | 테스트 |
|---|---|---|
NewBlacklistCache |
100% | ✅ |
IsBlacklisted |
100% | ✅ |
Add |
100% | ✅ |
LoadBulk |
100% | ✅ |
Size |
100% | ✅ |
Stop |
100% | ✅ |
cleanup |
100% | ✅ |
handleMessage |
100% | ✅ |
Verify (jwt) |
100% | ✅ |
scopeContains |
100% | ✅ |
matchesIssuer |
100% | ✅ |
matchesAudience |
100% | ✅ |
connect |
0% | ⚠️ Integration Test 필요 |
7.2 테스트 케이스
cache/blacklist_test.go:
func TestBlacklistCache_IsBlacklisted(t *testing.T) // 기본 동작
func TestBlacklistCache_ExpiredEntry(t *testing.T) // 만료 처리
func TestBlacklistCache_LoadBulk(t *testing.T) // 벌크 로드
func TestBlacklistCache_Size(t *testing.T) // 크기 계산
func TestBlacklistCache_Stop(t *testing.T) // 정상 종료
func TestBlacklistCache_Cleanup(t *testing.T) // 백그라운드 정리
func TestBlacklistCache_ConcurrentAccess(t *testing.T) // 동시성 (100 goroutines)
func TestBlacklistCache_Overwrite(t *testing.T) // 덮어쓰기
func TestBlacklistCache_EmptyJTI(t *testing.T) // 빈 JTI 처리
mq/consumer_test.go:
func TestBlacklistEvent_JSONParsing(t *testing.T) // JSON 파싱
func TestBlacklistConsumer_HandleMessage(t *testing.T) // 메시지 처리
func TestNewBlacklistConsumer(t *testing.T) // 생성
func TestBlacklistConsumer_Stop(t *testing.T) // 종료
jwt/verify_test.go:
func TestNewVerifier_Validation(t *testing.T) // 생성자 검증
func TestVerifier_Verify(t *testing.T) // 토큰 검증 (Valid, Expired, Invalid)
func TestVerifier_MissingJTI(t *testing.T) // JTI 누락 처리
func TestVerifier_InvalidIssuer(t *testing.T) // 발급자 검증
func TestVerifier_InvalidAudience(t *testing.T) // 대상 검증
func TestVerifier_AudienceAsArray(t *testing.T) // 배열 형태 aud 처리
func TestVerifier_NoScopeRequired(t *testing.T) // 스코프 없음 허용
func TestScopeContains(t *testing.T) // 스코프 검색
func TestMatchesIssuer(t *testing.T) // 발급자 매칭
func TestMatchesAudience(t *testing.T) // 대상 매칭
store/redis_test.go:
func TestNewWithClientNil(t *testing.T) // nil 클라이언트 처리
func TestIsBlacklistedTrue(t *testing.T) // 블랙리스트 조회 (존재)
func TestIsBlacklistedFalse(t *testing.T) // 블랙리스트 조회 (없음)
func TestIsBlacklistedRedisError(t *testing.T) // Redis 에러 처리
func TestClose(t *testing.T) // 정상 종료
func TestCloseError(t *testing.T) // 종료 에러 처리
func TestCloseNilStore(t *testing.T) // nil Store 처리
func TestCloseNilClient(t *testing.T) // nil Client 처리
func TestBlacklistKey(t *testing.T) // 키 생성
7.3 벤치마크
$ go test -bench=. ./internal/cache/...
BenchmarkIsBlacklisted-8 12000000 100 ns/op
BenchmarkIsBlacklisted_Miss-8 15000000 80 ns/op
BenchmarkAdd-8 8000000 150 ns/op
BenchmarkConcurrentIsBlacklisted-8 5000000 250 ns/op
결과: O(1) 조회 성능 확인 (~100ns/op)
7.4 Python 테스트
# test_blacklist_publisher.py (11 tests)
class TestBlacklistEventPublisher:
def test_init(self): ...
def test_ensure_connection_success(self): ...
def test_publish_add_success(self): ...
def test_publish_add_connection_failure(self): ...
def test_publish_add_publish_failure(self): ...
def test_close(self): ...
def test_close_without_connection(self): ...
def test_reconnect_on_closed_connection(self): ...
class TestGetBlacklistPublisher:
def test_returns_none_when_no_amqp_url(self): ...
def test_returns_singleton(self): ...
def test_returns_none_on_init_failure(self): ...
# test_token_blacklist.py (8 tests)
class TestTokenBlacklist:
async def test_add_stores_in_redis(self): ...
async def test_add_skips_expired_token(self): ...
async def test_add_publishes_event(self): ...
async def test_add_handles_publish_failure(self): ...
async def test_contains_returns_true_when_exists(self): ...
async def test_contains_returns_false_when_not_exists(self): ...
def test_key_format(self): ...
8. 관련 PR
| PR | 설명 | 상태 |
|---|---|---|
| #236 | ext-authz Local Cache 구현 (Go) | ✅ Merged |
| #238 | auth-api Publisher 구현 (Python) | ✅ Merged |
| #239 | NetworkPolicy 추가 | ✅ Merged |
| #240 | expires_at 필드명 수정 | ✅ Merged |