Files
mcias/internal/grpcserver/grpcserver_test.go
Kyle Isom 7e5fc9f111 Fix flaky gRPC renewal test timing
Increase token lifetime from 2s to 4s in TestRenewToken to prevent
the token from expiring before the gRPC call completes through bufconn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:08:44 -07:00

881 lines
27 KiB
Go

// Tests for the gRPC server package.
//
// All tests use bufconn so no network sockets are opened. TLS is omitted
// at the test layer (insecure credentials); TLS enforcement is the responsibility
// of cmd/mciassrv which wraps the listener.
package grpcserver
import (
"context"
"crypto/ed25519"
"crypto/rand"
"io"
"log/slog"
"net"
"strings"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
)
const (
testIssuer = "https://auth.example.com"
bufConnSize = 1024 * 1024
)
// testEnv holds all resources for a single test's gRPC server.
type testEnv struct {
db *db.DB
cfg *config.Config
conn *grpc.ClientConn
priv ed25519.PrivateKey
pub ed25519.PublicKey
masterKey []byte
}
// newTestEnv spins up an in-process gRPC server using bufconn and returns
// a client connection to it. All resources are cleaned up via t.Cleanup.
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate db: %v", err)
}
masterKey := make([]byte, 32)
if _, err := rand.Read(masterKey); err != nil {
t.Fatalf("generate master key: %v", err)
}
cfg := config.NewTestConfig(testIssuer)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := New(database, cfg, priv, pub, masterKey, logger)
grpcSrv := srv.GRPCServer()
lis := bufconn.Listen(bufConnSize)
go func() {
_ = grpcSrv.Serve(lis) // returns on listener close; error ignored
}()
conn, err := grpc.NewClient(
"passthrough://bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial bufconn: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
grpcSrv.Stop()
_ = lis.Close()
_ = database.Close()
})
return &testEnv{
db: database,
priv: priv,
pub: pub,
masterKey: masterKey,
cfg: cfg,
conn: conn,
}
}
// createHumanAccount creates a human account with the given username and
// a fixed password "testpass123" directly in the database.
func (e *testEnv) createHumanAccount(t *testing.T, username string) *model.Account {
t.Helper()
hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4})
if err != nil {
t.Fatalf("hash password: %v", err)
}
acct, err := e.db.CreateAccount(username, model.AccountTypeHuman, hash)
if err != nil {
t.Fatalf("create account: %v", err)
}
return acct
}
// issueAdminToken creates an account with admin role, issues a JWT, tracks it in
// the DB, and returns the token string.
func (e *testEnv) issueAdminToken(t *testing.T, username string) (string, *model.Account) {
t.Helper()
acct := e.createHumanAccount(t, username)
if err := e.db.GrantRole(acct.ID, "admin", nil); err != nil {
t.Fatalf("grant admin role: %v", err)
}
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return tokenStr, acct
}
// issueUserToken issues a regular (non-admin) token for an account.
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
t.Helper()
return e.issueShortToken(t, acct, time.Hour)
}
func (e *testEnv) issueShortToken(t *testing.T, acct *model.Account, expiry time.Duration) string {
t.Helper()
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, expiry)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return tokenStr
}
// authCtx returns a context with the Bearer token as gRPC metadata.
func authCtx(tok string) context.Context {
return metadata.AppendToOutgoingContext(context.Background(),
"authorization", "Bearer "+tok)
}
// ---- AdminService tests ----
// TestHealth verifies the public Health RPC requires no auth and returns "ok".
func TestHealth(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAdminServiceClient(e.conn)
resp, err := cl.Health(context.Background(), &mciasv1.HealthRequest{})
if err != nil {
t.Fatalf("Health: %v", err)
}
if resp.Status != "ok" {
t.Errorf("Health: got status %q, want %q", resp.Status, "ok")
}
}
// TestGetPublicKey verifies the public GetPublicKey RPC returns JWK fields.
func TestGetPublicKey(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAdminServiceClient(e.conn)
resp, err := cl.GetPublicKey(context.Background(), &mciasv1.GetPublicKeyRequest{})
if err != nil {
t.Fatalf("GetPublicKey: %v", err)
}
if resp.Kty != "OKP" {
t.Errorf("GetPublicKey: kty=%q, want OKP", resp.Kty)
}
if resp.Crv != "Ed25519" {
t.Errorf("GetPublicKey: crv=%q, want Ed25519", resp.Crv)
}
if resp.X == "" {
t.Error("GetPublicKey: x field is empty")
}
}
// ---- Auth interceptor tests ----
// TestAuthRequired verifies that protected RPCs reject calls with no token.
func TestAuthRequired(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAuthServiceClient(e.conn)
// Logout requires auth; call without any metadata.
_, err := cl.Logout(context.Background(), &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("Logout without token: expected error, got nil")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("not a gRPC status error: %v", err)
}
if st.Code() != codes.Unauthenticated {
t.Errorf("Logout without token: got code %v, want Unauthenticated", st.Code())
}
}
// TestInvalidTokenRejected verifies that a malformed token is rejected.
func TestInvalidTokenRejected(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx("not.a.valid.jwt")
_, err := cl.Logout(ctx, &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("Logout with invalid token: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestRevokedTokenRejected verifies that a revoked token cannot be used.
func TestRevokedTokenRejected(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "revokeduser")
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
// Revoke it before using it.
if err := e.db.RevokeToken(claims.JTI, "test"); err != nil {
t.Fatalf("revoke token: %v", err)
}
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tokenStr)
_, err = cl.Logout(ctx, &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("Logout with revoked token: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestNonAdminCannotCallAdminRPC verifies that a regular user is denied access
// to admin-only RPCs (PermissionDenied, not Unauthenticated).
func TestNonAdminCannotCallAdminRPC(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "regularuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAccountServiceClient(e.conn)
ctx := authCtx(tok)
_, err := cl.ListAccounts(ctx, &mciasv1.ListAccountsRequest{})
if err == nil {
t.Fatal("ListAccounts as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
}
// ---- AuthService tests ----
// TestLogin verifies successful login via gRPC.
func TestLogin(t *testing.T) {
e := newTestEnv(t)
_ = e.createHumanAccount(t, "loginuser")
cl := mciasv1.NewAuthServiceClient(e.conn)
resp, err := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "loginuser",
Password: "testpass123",
})
if err != nil {
t.Fatalf("Login: %v", err)
}
if resp.Token == "" {
t.Error("Login: returned empty token")
}
}
// TestLoginWrongPassword verifies that wrong-password returns Unauthenticated.
func TestLoginWrongPassword(t *testing.T) {
e := newTestEnv(t)
_ = e.createHumanAccount(t, "loginuser2")
cl := mciasv1.NewAuthServiceClient(e.conn)
_, err := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "loginuser2",
Password: "wrongpassword",
})
if err == nil {
t.Fatal("Login with wrong password: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestLoginUnknownUser verifies that unknown-user returns the same error as
// wrong-password (prevents user enumeration).
func TestLoginUnknownUser(t *testing.T) {
e := newTestEnv(t)
cl := mciasv1.NewAuthServiceClient(e.conn)
_, err := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "nosuchuser",
Password: "whatever",
})
if err == nil {
t.Fatal("Login for unknown user: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("got code %v, want Unauthenticated", st.Code())
}
}
// TestLogout verifies that a valid token can log itself out.
func TestLogout(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "logoutuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
_, err := cl.Logout(ctx, &mciasv1.LogoutRequest{})
if err != nil {
t.Fatalf("Logout: %v", err)
}
// Second call with the same token must fail (token now revoked).
_, err = cl.Logout(authCtx(tok), &mciasv1.LogoutRequest{})
if err == nil {
t.Fatal("second Logout with revoked token: expected error, got nil")
}
}
// TestRenewToken verifies that a valid token can be renewed after 50% of its
// lifetime has elapsed (SEC-03).
func TestRenewToken(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "renewuser")
// Issue a short-lived token (4s) so we can wait past the 50% threshold.
tok := e.issueShortToken(t, acct, 4*time.Second)
// Wait for >50% of lifetime to elapse.
time.Sleep(2100 * time.Millisecond)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
resp, err := cl.RenewToken(ctx, &mciasv1.RenewTokenRequest{})
if err != nil {
t.Fatalf("RenewToken: %v", err)
}
if resp.Token == "" {
t.Error("RenewToken: returned empty token")
}
if resp.Token == tok {
t.Error("RenewToken: returned same token instead of a fresh one")
}
}
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
// of its lifetime has elapsed (SEC-03).
func TestRenewTokenTooEarly(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "renewearlyuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAuthServiceClient(e.conn)
ctx := authCtx(tok)
_, err := cl.RenewToken(ctx, &mciasv1.RenewTokenRequest{})
if err == nil {
t.Fatal("RenewToken: expected error for early renewal, got nil")
}
st, ok := status.FromError(err)
if !ok || st.Code() != codes.InvalidArgument {
t.Fatalf("RenewToken: expected InvalidArgument, got %v", err)
}
if !strings.Contains(st.Message(), "not yet eligible for renewal") {
t.Errorf("RenewToken: expected eligibility message, got: %s", st.Message())
}
}
// ---- TokenService tests ----
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
// a good token and valid=false for a garbage input (no Unauthenticated error).
func TestValidateToken(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "validateuser")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewTokenServiceClient(e.conn)
// Valid token.
resp, err := cl.ValidateToken(context.Background(), &mciasv1.ValidateTokenRequest{Token: tok})
if err != nil {
t.Fatalf("ValidateToken (good): %v", err)
}
if !resp.Valid {
t.Error("ValidateToken: got valid=false for a good token")
}
// Invalid token: should return valid=false, not an RPC error.
resp, err = cl.ValidateToken(context.Background(), &mciasv1.ValidateTokenRequest{Token: "garbage"})
if err != nil {
t.Fatalf("ValidateToken (bad): unexpected RPC error: %v", err)
}
if resp.Valid {
t.Error("ValidateToken: got valid=true for a garbage token")
}
}
// TestIssueServiceTokenRequiresAdmin verifies that non-admin cannot issue tokens.
func TestIssueServiceTokenRequiresAdmin(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "notadmin")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewTokenServiceClient(e.conn)
_, err := cl.IssueServiceToken(authCtx(tok), &mciasv1.IssueServiceTokenRequest{AccountId: acct.UUID})
if err == nil {
t.Fatal("IssueServiceToken as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
}
// ---- AccountService tests ----
// TestListAccountsAdminOnly verifies that ListAccounts requires admin role.
func TestListAccountsAdminOnly(t *testing.T) {
e := newTestEnv(t)
// Non-admin call.
acct := e.createHumanAccount(t, "nonadmin")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewAccountServiceClient(e.conn)
_, err := cl.ListAccounts(authCtx(tok), &mciasv1.ListAccountsRequest{})
if err == nil {
t.Fatal("ListAccounts as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
// Admin call.
adminTok, _ := e.issueAdminToken(t, "adminuser")
resp, err := cl.ListAccounts(authCtx(adminTok), &mciasv1.ListAccountsRequest{})
if err != nil {
t.Fatalf("ListAccounts as admin: %v", err)
}
if len(resp.Accounts) == 0 {
t.Error("ListAccounts: expected at least one account")
}
}
// TestCreateAndGetAccount exercises the full create→get lifecycle.
func TestCreateAndGetAccount(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin2")
cl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := cl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "newuser",
Password: "securepassword1",
AccountType: "human",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if createResp.Account == nil {
t.Fatal("CreateAccount: returned nil account")
}
if createResp.Account.Id == "" {
t.Error("CreateAccount: returned empty UUID")
}
// Security: credential fields must not appear in the response.
// The Account proto has no password_hash or totp_secret fields by design.
// Verify via GetAccount too.
getResp, err := cl.GetAccount(authCtx(adminTok), &mciasv1.GetAccountRequest{Id: createResp.Account.Id})
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if getResp.Account.Username != "newuser" {
t.Errorf("GetAccount: username=%q, want %q", getResp.Account.Username, "newuser")
}
}
// TestUpdateAccount verifies that account status can be changed.
func TestUpdateAccount(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin3")
cl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := cl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "updateme",
Password: "pass123456789",
AccountType: "human",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
id := createResp.Account.Id
_, err = cl.UpdateAccount(authCtx(adminTok), &mciasv1.UpdateAccountRequest{
Id: id,
Status: "inactive",
})
if err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
getResp, err := cl.GetAccount(authCtx(adminTok), &mciasv1.GetAccountRequest{Id: id})
if err != nil {
t.Fatalf("GetAccount after update: %v", err)
}
if getResp.Account.Status != "inactive" {
t.Errorf("after update: status=%q, want inactive", getResp.Account.Status)
}
}
// TestSetAndGetRoles verifies that roles can be assigned and retrieved.
func TestSetAndGetRoles(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin4")
cl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := cl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "roleuser",
Password: "pass123456789",
AccountType: "human",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
id := createResp.Account.Id
_, err = cl.SetRoles(authCtx(adminTok), &mciasv1.SetRolesRequest{
Id: id,
Roles: []string{"admin", "user"},
})
if err != nil {
t.Fatalf("SetRoles: %v", err)
}
getRolesResp, err := cl.GetRoles(authCtx(adminTok), &mciasv1.GetRolesRequest{Id: id})
if err != nil {
t.Fatalf("GetRoles: %v", err)
}
if len(getRolesResp.Roles) != 2 {
t.Errorf("GetRoles: got %d roles, want 2", len(getRolesResp.Roles))
}
}
// ---- CredentialService tests ----
// TestSetAndGetPGCreds verifies that PG credentials can be stored and retrieved.
// Security: the password is decrypted only in the GetPGCreds response; it is
// never present in account list or other responses.
func TestSetAndGetPGCreds(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin5")
// Create a system account to hold the PG credentials.
accCl := mciasv1.NewAccountServiceClient(e.conn)
createResp, err := accCl.CreateAccount(authCtx(adminTok), &mciasv1.CreateAccountRequest{
Username: "sysaccount",
AccountType: "system",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
accountID := createResp.Account.Id
credCl := mciasv1.NewCredentialServiceClient(e.conn)
_, err = credCl.SetPGCreds(authCtx(adminTok), &mciasv1.SetPGCredsRequest{
Id: accountID,
Creds: &mciasv1.PGCreds{
Host: "db.example.com",
Port: 5432,
Database: "mydb",
Username: "myuser",
Password: "supersecret",
},
})
if err != nil {
t.Fatalf("SetPGCreds: %v", err)
}
getResp, err := credCl.GetPGCreds(authCtx(adminTok), &mciasv1.GetPGCredsRequest{Id: accountID})
if err != nil {
t.Fatalf("GetPGCreds: %v", err)
}
if getResp.Creds == nil {
t.Fatal("GetPGCreds: returned nil creds")
}
if getResp.Creds.Password != "supersecret" {
t.Errorf("GetPGCreds: password=%q, want supersecret", getResp.Creds.Password)
}
if getResp.Creds.Host != "db.example.com" {
t.Errorf("GetPGCreds: host=%q, want db.example.com", getResp.Creds.Host)
}
}
// TestPGCredsRequireAdmin verifies that non-admin cannot access PG creds.
func TestPGCredsRequireAdmin(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "notadmin2")
tok := e.issueUserToken(t, acct)
cl := mciasv1.NewCredentialServiceClient(e.conn)
_, err := cl.GetPGCreds(authCtx(tok), &mciasv1.GetPGCredsRequest{Id: acct.UUID})
if err == nil {
t.Fatal("GetPGCreds as non-admin: expected error, got nil")
}
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Errorf("got code %v, want PermissionDenied", st.Code())
}
}
// ---- Security: credential fields absent from responses ----
// TestCredentialFieldsAbsentFromAccountResponse verifies that account responses
// never include password_hash or totp_secret fields. The Account proto message
// does not define these fields, providing compile-time enforcement. This test
// provides a runtime confirmation by checking the returned Account struct.
func TestCredentialFieldsAbsentFromAccountResponse(t *testing.T) {
e := newTestEnv(t)
adminTok, _ := e.issueAdminToken(t, "admin6")
cl := mciasv1.NewAccountServiceClient(e.conn)
resp, err := cl.ListAccounts(authCtx(adminTok), &mciasv1.ListAccountsRequest{})
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
for _, a := range resp.Accounts {
// Account proto only has: id, username, account_type, status,
// totp_enabled, created_at, updated_at. No credential fields.
// This loop body intentionally checks the fields that exist;
// the absence of credential fields is enforced by the proto definition.
if a.Id == "" {
t.Error("account has empty id")
}
}
}
// ---- grpcClientIP tests (SEC-06) ----
// fakeAddr implements net.Addr for testing peer contexts.
type fakeAddr struct {
addr string
network string
}
func (a fakeAddr) String() string { return a.addr }
func (a fakeAddr) Network() string { return a.network }
// TestGRPCClientIP_NoProxy verifies that when no trusted proxy is configured
// the function returns the peer IP directly.
func TestGRPCClientIP_NoProxy(t *testing.T) {
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "10.0.0.5:54321", network: "tcp"},
})
got := grpcClientIP(ctx, nil)
if got != "10.0.0.5" {
t.Errorf("grpcClientIP(no proxy) = %q, want %q", got, "10.0.0.5")
}
}
// TestGRPCClientIP_TrustedProxy_XForwardedFor verifies that when the peer
// matches the trusted proxy, the real client IP is extracted from
// x-forwarded-for metadata.
func TestGRPCClientIP_TrustedProxy_XForwardedFor(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs("x-forwarded-for", "203.0.113.50, 10.0.0.1")
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "203.0.113.50" {
t.Errorf("grpcClientIP(xff) = %q, want %q", got, "203.0.113.50")
}
}
// TestGRPCClientIP_TrustedProxy_XRealIP verifies that x-real-ip is preferred
// over x-forwarded-for when both are present.
func TestGRPCClientIP_TrustedProxy_XRealIP(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs(
"x-real-ip", "198.51.100.10",
"x-forwarded-for", "203.0.113.50",
)
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "198.51.100.10" {
t.Errorf("grpcClientIP(x-real-ip preferred) = %q, want %q", got, "198.51.100.10")
}
}
// TestGRPCClientIP_UntrustedPeer_IgnoresHeaders verifies that forwarded
// headers are ignored when the peer does NOT match the trusted proxy.
// Security: This prevents IP-spoofing by untrusted clients.
func TestGRPCClientIP_UntrustedPeer_IgnoresHeaders(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
// Peer is NOT the trusted proxy.
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "10.0.0.99:54321", network: "tcp"},
})
md := metadata.Pairs(
"x-forwarded-for", "203.0.113.50",
"x-real-ip", "198.51.100.10",
)
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "10.0.0.99" {
t.Errorf("grpcClientIP(untrusted peer) = %q, want %q", got, "10.0.0.99")
}
}
// TestGRPCClientIP_TrustedProxy_NoHeaders verifies that when the peer matches
// the proxy but no forwarded headers are set, the peer IP is returned as fallback.
func TestGRPCClientIP_TrustedProxy_NoHeaders(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
got := grpcClientIP(ctx, proxyIP)
if got != "192.168.1.1" {
t.Errorf("grpcClientIP(proxy, no headers) = %q, want %q", got, "192.168.1.1")
}
}
// TestGRPCClientIP_TrustedProxy_InvalidHeader verifies that invalid IPs in
// headers are ignored and the peer IP is returned.
func TestGRPCClientIP_TrustedProxy_InvalidHeader(t *testing.T) {
proxyIP := net.ParseIP("192.168.1.1")
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
})
md := metadata.Pairs("x-forwarded-for", "not-an-ip")
ctx = metadata.NewIncomingContext(ctx, md)
got := grpcClientIP(ctx, proxyIP)
if got != "192.168.1.1" {
t.Errorf("grpcClientIP(invalid header) = %q, want %q", got, "192.168.1.1")
}
}
// TestGRPCClientIP_NoPeer verifies that an empty string is returned when
// there is no peer in the context.
func TestGRPCClientIP_NoPeer(t *testing.T) {
got := grpcClientIP(context.Background(), nil)
if got != "" {
t.Errorf("grpcClientIP(no peer) = %q, want %q", got, "")
}
}
// TestLoginLockedAccountReturnsUnauthenticated verifies that a locked-out
// account gets the same gRPC Unauthenticated / "invalid credentials" as a
// wrong-password attempt, preventing user-enumeration via lockout
// differentiation (SEC-02).
func TestLoginLockedAccountReturnsUnauthenticated(t *testing.T) {
e := newTestEnv(t)
acct := e.createHumanAccount(t, "lockgrpc")
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
for range db.LockoutThreshold {
if err := e.db.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
locked, err := e.db.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
cl := mciasv1.NewAuthServiceClient(e.conn)
// Attempt login on the locked account.
_, lockedErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "lockgrpc",
Password: "testpass123",
})
if lockedErr == nil {
t.Fatal("Login on locked account: expected error, got nil")
}
// Attempt login with wrong password for comparison.
_, wrongErr := cl.Login(context.Background(), &mciasv1.LoginRequest{
Username: "lockgrpc",
Password: "wrongpassword",
})
if wrongErr == nil {
t.Fatal("Login with wrong password: expected error, got nil")
}
lockedSt, _ := status.FromError(lockedErr)
wrongSt, _ := status.FromError(wrongErr)
// Both must return Unauthenticated, not ResourceExhausted.
if lockedSt.Code() != codes.Unauthenticated {
t.Errorf("locked: got code %v, want Unauthenticated", lockedSt.Code())
}
if wrongSt.Code() != codes.Unauthenticated {
t.Errorf("wrong password: got code %v, want Unauthenticated", wrongSt.Code())
}
// Messages must be identical.
if lockedSt.Message() != wrongSt.Message() {
t.Errorf("locked message %q differs from wrong-password message %q",
lockedSt.Message(), wrongSt.Message())
}
if lockedSt.Message() != "invalid credentials" {
t.Errorf("locked message = %q, want %q", lockedSt.Message(), "invalid credentials")
}
}