package admin import ( "context" "crypto/rand" "testing" "time" "github.com/wangjia/pangolin/server/internal/totp" ) // newTestAdmin seeds an active admin and returns its TOTP secret. func newTestAdmin(t *testing.T, store *fakeStore, key []byte, username, password string) string { t.Helper() hash, err := HashPassword(password) if err != nil { t.Fatal(err) } secret, err := totp.GenerateSecret() if err != nil { t.Fatal(err) } enc, err := EncryptSecret(key, secret) if err != nil { t.Fatal(err) } store.admins[username] = &Admin{ID: 1, Username: username, PwHash: hash, TOTPSecretEnc: enc, Status: "active"} return secret } func newTestAuth(t *testing.T) (*Authenticator, *fakeStore, []byte) { t.Helper() rdb, _ := newTestRedis(t) key := make([]byte, 32) if _, err := rand.Read(key); err != nil { t.Fatal(err) } store := newFakeStore() cfg := &Config{SecretKey: key, LoginFailMax: 3, LoginLockDuration: time.Minute, SessionTTL: 30 * time.Minute} sessions := NewSessionStore(rdb, cfg.SessionTTL) sec := NewSecurityLog(store, nil) return NewAuthenticator(store, sessions, rdb, cfg, sec), store, key } func TestLogin_Success(t *testing.T) { auth, store, key := newTestAuth(t) secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") code, _ := totp.Code(secret, time.Now().UTC()) sid, sess, err := auth.Login(context.Background(), "alice", "s3cret-pass", code, "127.0.0.1") if err != nil { t.Fatalf("login failed: %v", err) } if sid == "" || sess == nil { t.Fatal("no session returned") } if _, ok := store.lastLogin[1]; !ok { t.Error("last login not recorded") } if len(store.auditFor("admin_login_ok")) != 1 { t.Error("successful login not audited") } } func TestLogin_WrongPassword(t *testing.T) { auth, store, key := newTestAuth(t) secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") code, _ := totp.Code(secret, time.Now().UTC()) _, _, err := auth.Login(context.Background(), "alice", "WRONG", code, "127.0.0.1") if err != ErrInvalidCredentials { t.Fatalf("err = %v; want ErrInvalidCredentials", err) } if len(store.auditFor("admin_login_fail")) != 1 { t.Error("failed login not audited") } } func TestLogin_WrongTOTP(t *testing.T) { auth, store, key := newTestAuth(t) _ = newTestAdmin(t, store, key, "alice", "s3cret-pass") _, _, err := auth.Login(context.Background(), "alice", "s3cret-pass", "000000", "127.0.0.1") if err != ErrInvalidCredentials { t.Fatalf("err = %v; want ErrInvalidCredentials", err) } } func TestLogin_UnknownUser(t *testing.T) { auth, _, _ := newTestAuth(t) _, _, err := auth.Login(context.Background(), "ghost", "x", "000000", "127.0.0.1") if err != ErrInvalidCredentials { t.Fatalf("err = %v; want ErrInvalidCredentials", err) } } func TestLogin_LockoutAfterFailures(t *testing.T) { auth, store, key := newTestAuth(t) secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") ctx := context.Background() // 3 failures hit the cap. for i := 0; i < 3; i++ { if _, _, err := auth.Login(ctx, "alice", "WRONG", "000000", "127.0.0.1"); err != ErrInvalidCredentials { t.Fatalf("attempt %d err = %v", i, err) } } // Now even a correct credential is locked out. code, _ := totp.Code(secret, time.Now().UTC()) if _, _, err := auth.Login(ctx, "alice", "s3cret-pass", code, "127.0.0.1"); err != ErrLockedOut { t.Fatalf("err = %v; want ErrLockedOut", err) } if len(store.auditFor("admin_login_locked")) == 0 { t.Error("lockout not audited") } }