// 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" "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/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() 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) } 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. func TestRenewToken(t *testing.T) { e := newTestEnv(t) acct := e.createHumanAccount(t, "renewuser") tok := e.issueUserToken(t, acct) 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") } } // ---- 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: "pass12345", 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: "pass12345", 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{"editor", "viewer"}, }) 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") } } }