- errorlint: use errors.Is for db.ErrNotFound comparisons in accountservice.go, credentialservice.go, tokenservice.go - gofmt/goimports: move mciasv1 alias into internal import group in auth.go, credentialservice.go, grpcserver.go, grpcserver_test.go - gosec G115: add nolint annotation on int32 port conversions in mciasgrpcctl/main.go and credentialservice.go (port validated as [1,65535] on input; overflow not reachable) - govet fieldalignment: reorder Server, grpcRateLimiter, grpcRateLimitEntry, testEnv structs to reduce GC bitmap size (96 -> 80 pointer bytes each) - ineffassign: remove intermediate grpcSrv = GRPCServer() call in cmd/mciassrv/main.go (immediately overwritten by TLS build) - staticcheck SA9003: replace empty if-body with _ = Serve(lis) in grpcserver_test.go 0 golangci-lint issues; 137 tests pass (go test -race ./...)
653 lines
19 KiB
Go
653 lines
19 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"
|
|
"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")
|
|
}
|
|
}
|
|
}
|