Files
mxaccessgw/clients/go/mxgateway/errors_auth_test.go
T
Joseph Doherty 9351ec1d2c feat(client-go): typed Unauthenticated vs PermissionDenied errors (#198)
Every RPC failure surfaced as a GatewayError with no way to tell a rejected
key from a missing scope, though docs/ClientLibrariesDesign.md requires the
two be treated distinctly. GatewayError now matches the new sentinels
ErrUnauthenticated and ErrPermissionDenied via errors.Is (by gRPC status code
only, so it works through the secret-redaction wrapper) and exposes Code().

Tests: errors_auth_test.go — sentinel matching per code, Code(), and a bufconn
AcknowledgeAlarm round trip proving the match survives the wire and the
password stays redacted (both behavioural tests fail without GatewayError.Is).
2026-11-06 16:08:51 -05:00

86 lines
2.7 KiB
Go

package mxgateway
import (
"context"
"errors"
"fmt"
"strings"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// #198 — the gateway answers a bad key with Unauthenticated and a missing scope
// with PermissionDenied; callers must be able to tell them apart with errors.Is.
func TestGatewayErrorMatchesAuthSentinelsByStatusCode(t *testing.T) {
cases := []struct {
name string
err error
unauthenticated bool
permissionDenied bool
}{
{"unauthenticated", status.Error(codes.Unauthenticated, "bad key"), true, false},
{"permission denied", status.Error(codes.PermissionDenied, "scope required"), false, true},
{"unavailable", status.Error(codes.Unavailable, "down"), false, false},
{"non-grpc", errors.New("plain"), false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := fmt.Errorf("outer: %w", &GatewayError{Op: "invoke", Err: tc.err})
if got := errors.Is(err, ErrUnauthenticated); got != tc.unauthenticated {
t.Fatalf("errors.Is(ErrUnauthenticated) = %v, want %v", got, tc.unauthenticated)
}
if got := errors.Is(err, ErrPermissionDenied); got != tc.permissionDenied {
t.Fatalf("errors.Is(ErrPermissionDenied) = %v, want %v", got, tc.permissionDenied)
}
})
}
}
func TestGatewayErrorCode(t *testing.T) {
if got := (&GatewayError{Err: status.Error(codes.PermissionDenied, "x")}).Code(); got != codes.PermissionDenied {
t.Fatalf("Code() = %v", got)
}
if got := (*GatewayError)(nil).Code(); got != codes.OK {
t.Fatalf("nil Code() = %v", got)
}
}
func TestAuthSentinelsSurviveTheWireAndRedaction(t *testing.T) {
for _, tc := range []struct {
code codes.Code
sentinel error
other error
}{
{codes.Unauthenticated, ErrUnauthenticated, ErrPermissionDenied},
{codes.PermissionDenied, ErrPermissionDenied, ErrUnauthenticated},
} {
t.Run(tc.code.String(), func(t *testing.T) {
fake := &fakeGatewayWithAlarms{acknowledgeError: status.Error(tc.code, "denied pw-secret")}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
AlarmFullReference: "Tank01.Level.HiHi",
OperatorUser: "alice",
OperatorPassword: "pw-secret",
})
if err == nil {
t.Fatal("AcknowledgeAlarm() returned no error")
}
if !errors.Is(err, tc.sentinel) {
t.Fatalf("errors.Is(%v) = false for %v", tc.sentinel, err)
}
if errors.Is(err, tc.other) {
t.Fatalf("errors.Is(%v) = true for %v", tc.other, err)
}
if strings.Contains(err.Error(), "pw-secret") {
t.Fatalf("error leaked the operator password: %v", err)
}
})
}
}