Files
mcias/internal/grpcserver/grpcserver_test.go
Kyle Isom d87b4b4042 Add vault seal/unseal lifecycle
- New internal/vault package: thread-safe Vault struct with
  seal/unseal state, key material zeroing, and key derivation
- REST: POST /v1/vault/unseal, POST /v1/vault/seal,
  GET /v1/vault/status; health returns sealed status
- UI: /unseal page with passphrase form, redirect when sealed
- gRPC: sealedInterceptor rejects RPCs when sealed
- Middleware: RequireUnsealed blocks all routes except exempt
  paths; RequireAuth reads pubkey from vault at request time
- Startup: server starts sealed when passphrase unavailable
- All servers share single *vault.Vault by pointer
- CSRF manager derives key lazily from vault

Security: Key material is zeroed on seal. Sealed middleware
runs before auth. Handlers fail closed if vault becomes sealed
mid-request. Unseal endpoint is rate-limited (3/s burst 5).
No CSRF on unseal page (no session to protect; chicken-and-egg
with master key). Passphrase never logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 23:55:37 -07:00

883 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"
"git.wntrmute.dev/kyle/mcias/internal/vault"
)
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))
v := vault.NewUnsealed(masterKey, priv, pub)
srv := New(database, cfg, v, 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")
}
}