diff --git a/internal/grpcserver/handlers_test.go b/internal/grpcserver/handlers_test.go new file mode 100644 index 0000000..9104006 --- /dev/null +++ b/internal/grpcserver/handlers_test.go @@ -0,0 +1,815 @@ +package grpcserver + +import ( + "context" + "encoding/json" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "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" + + mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth" + + pb "git.wntrmute.dev/kyle/mcns/gen/mcns/v1" + "git.wntrmute.dev/kyle/mcns/internal/db" +) + +// mockMCIAS starts a fake MCIAS HTTP server for token validation. +// Recognized tokens: +// - "admin-token" -> valid, username=admin-uuid, roles=[admin] +// - "user-token" -> valid, username=user-uuid, roles=[user] +// - anything else -> invalid +func mockMCIAS(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + switch req.Token { + case "admin-token": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "valid": true, + "username": "admin-uuid", + "account_type": "human", + "roles": []string{"admin"}, + }) + case "user-token": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "valid": true, + "username": "user-uuid", + "account_type": "human", + "roles": []string{"user"}, + }) + default: + _ = json.NewEncoder(w).Encode(map[string]interface{}{"valid": false}) + } + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// testAuthenticator creates an mcdsl/auth.Authenticator that talks to the given mock MCIAS. +func testAuthenticator(t *testing.T, serverURL string) *mcdslauth.Authenticator { + t.Helper() + a, err := mcdslauth.New(mcdslauth.Config{ServerURL: serverURL}, slog.Default()) + if err != nil { + t.Fatalf("auth.New: %v", err) + } + return a +} + +// openTestDB creates a temporary test database with migrations applied. +func openTestDB(t *testing.T) *db.DB { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + d, err := db.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = d.Close() }) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + return d +} + +// startTestServer creates a gRPC server with auth interceptors and returns +// a connected client. Passing empty cert/key strings skips TLS. +func startTestServer(t *testing.T, deps Deps) *grpc.ClientConn { + t.Helper() + + srv, err := New("", "", deps, slog.Default()) + if err != nil { + t.Fatalf("New: %v", err) + } + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + + go func() { + _ = srv.Serve(lis) + }() + t.Cleanup(func() { srv.GracefulStop() }) + + //nolint:gosec // insecure credentials for testing only + cc, err := grpc.NewClient( + lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { _ = cc.Close() }) + + return cc +} + +// withAuth adds a bearer token to the outgoing context metadata. +func withAuth(ctx context.Context, token string) context.Context { + return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token) +} + +// seedZone creates a test zone and returns it. +func seedZone(t *testing.T, database *db.DB, name string) *db.Zone { + t.Helper() + zone, err := database.CreateZone(name, "ns1.example.com.", "admin.example.com.", 3600, 600, 86400, 300) + if err != nil { + t.Fatalf("seed zone %q: %v", name, err) + } + return zone +} + +// seedRecord creates a test A record and returns it. +func seedRecord(t *testing.T, database *db.DB, zoneName, name, value string) *db.Record { + t.Helper() + rec, err := database.CreateRecord(zoneName, name, "A", value, 300) + if err != nil { + t.Fatalf("seed record %s.%s: %v", name, zoneName, err) + } + return rec +} + +// --------------------------------------------------------------------------- +// Admin tests +// --------------------------------------------------------------------------- + +func TestHealthBypassesAuth(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + client := pb.NewAdminServiceClient(cc) + + // No auth token -- should still succeed because Health is public. + resp, err := client.Health(context.Background(), &pb.HealthRequest{}) + if err != nil { + t.Fatalf("Health should not require auth: %v", err) + } + if resp.Status != "ok" { + t.Fatalf("Health status: got %q, want %q", resp.Status, "ok") + } +} + +// --------------------------------------------------------------------------- +// Zone tests +// --------------------------------------------------------------------------- + +func TestListZones(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "user-token") + client := pb.NewZoneServiceClient(cc) + + resp, err := client.ListZones(ctx, &pb.ListZonesRequest{}) + if err != nil { + t.Fatalf("ListZones: %v", err) + } + // Seed migration creates 2 zones. + if len(resp.Zones) != 2 { + t.Fatalf("got %d zones, want 2 (seed zones)", len(resp.Zones)) + } +} + +func TestGetZoneFound(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "user-token") + client := pb.NewZoneServiceClient(cc) + + zone, err := client.GetZone(ctx, &pb.GetZoneRequest{Name: "example.com"}) + if err != nil { + t.Fatalf("GetZone: %v", err) + } + if zone.Name != "example.com" { + t.Fatalf("got name %q, want %q", zone.Name, "example.com") + } +} + +func TestGetZoneNotFound(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "user-token") + client := pb.NewZoneServiceClient(cc) + + _, err := client.GetZone(ctx, &pb.GetZoneRequest{Name: "nonexistent.com"}) + if err == nil { + t.Fatal("expected error for nonexistent zone") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status, got %v", err) + } + if st.Code() != codes.NotFound { + t.Fatalf("code: got %v, want NotFound", st.Code()) + } +} + +func TestCreateZoneSuccess(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewZoneServiceClient(cc) + + zone, err := client.CreateZone(ctx, &pb.CreateZoneRequest{ + Name: "newzone.com", + PrimaryNs: "ns1.newzone.com.", + AdminEmail: "admin.newzone.com.", + Refresh: 3600, + Retry: 600, + Expire: 86400, + MinimumTtl: 300, + }) + if err != nil { + t.Fatalf("CreateZone: %v", err) + } + if zone.Name != "newzone.com" { + t.Fatalf("got name %q, want %q", zone.Name, "newzone.com") + } + if zone.Serial == 0 { + t.Fatal("serial should not be zero") + } +} + +func TestCreateZoneDuplicate(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewZoneServiceClient(cc) + + _, err := client.CreateZone(ctx, &pb.CreateZoneRequest{ + Name: "example.com", + PrimaryNs: "ns1.example.com.", + AdminEmail: "admin.example.com.", + }) + if err == nil { + t.Fatal("expected error for duplicate zone") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status, got %v", err) + } + if st.Code() != codes.AlreadyExists { + t.Fatalf("code: got %v, want AlreadyExists", st.Code()) + } +} + +func TestUpdateZone(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + original := seedZone(t, database, "example.com") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewZoneServiceClient(cc) + + updated, err := client.UpdateZone(ctx, &pb.UpdateZoneRequest{ + Name: "example.com", + PrimaryNs: "ns2.example.com.", + AdminEmail: "newadmin.example.com.", + Refresh: 7200, + Retry: 1200, + Expire: 172800, + MinimumTtl: 600, + }) + if err != nil { + t.Fatalf("UpdateZone: %v", err) + } + if updated.PrimaryNs != "ns2.example.com." { + t.Fatalf("got primary_ns %q, want %q", updated.PrimaryNs, "ns2.example.com.") + } + if updated.Serial <= original.Serial { + t.Fatalf("serial should have incremented: %d <= %d", updated.Serial, original.Serial) + } +} + +func TestDeleteZone(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewZoneServiceClient(cc) + + _, err := client.DeleteZone(ctx, &pb.DeleteZoneRequest{Name: "example.com"}) + if err != nil { + t.Fatalf("DeleteZone: %v", err) + } + + // Verify it is gone. + _, err = client.GetZone(withAuth(context.Background(), "user-token"), &pb.GetZoneRequest{Name: "example.com"}) + if err == nil { + t.Fatal("expected NotFound after delete") + } + st, _ := status.FromError(err) + if st.Code() != codes.NotFound { + t.Fatalf("code: got %v, want NotFound", st.Code()) + } +} + +func TestDeleteZoneNotFound(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewZoneServiceClient(cc) + + _, err := client.DeleteZone(ctx, &pb.DeleteZoneRequest{Name: "nonexistent.com"}) + if err == nil { + t.Fatal("expected error for nonexistent zone") + } + st, _ := status.FromError(err) + if st.Code() != codes.NotFound { + t.Fatalf("code: got %v, want NotFound", st.Code()) + } +} + +// --------------------------------------------------------------------------- +// Record tests +// --------------------------------------------------------------------------- + +func TestListRecords(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + seedRecord(t, database, "example.com", "www", "10.0.0.1") + seedRecord(t, database, "example.com", "mail", "10.0.0.2") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "user-token") + client := pb.NewRecordServiceClient(cc) + + resp, err := client.ListRecords(ctx, &pb.ListRecordsRequest{Zone: "example.com"}) + if err != nil { + t.Fatalf("ListRecords: %v", err) + } + if len(resp.Records) != 2 { + t.Fatalf("got %d records, want 2", len(resp.Records)) + } +} + +func TestGetRecordFound(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + rec := seedRecord(t, database, "example.com", "www", "10.0.0.1") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "user-token") + client := pb.NewRecordServiceClient(cc) + + got, err := client.GetRecord(ctx, &pb.GetRecordRequest{Id: rec.ID}) + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + if got.Name != "www" { + t.Fatalf("got name %q, want %q", got.Name, "www") + } + if got.Value != "10.0.0.1" { + t.Fatalf("got value %q, want %q", got.Value, "10.0.0.1") + } +} + +func TestGetRecordNotFound(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "user-token") + client := pb.NewRecordServiceClient(cc) + + _, err := client.GetRecord(ctx, &pb.GetRecordRequest{Id: 999999}) + if err == nil { + t.Fatal("expected error for nonexistent record") + } + st, _ := status.FromError(err) + if st.Code() != codes.NotFound { + t.Fatalf("code: got %v, want NotFound", st.Code()) + } +} + +func TestCreateRecordSuccess(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + rec, err := client.CreateRecord(ctx, &pb.CreateRecordRequest{ + Zone: "example.com", + Name: "www", + Type: "A", + Value: "10.0.0.1", + Ttl: 300, + }) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if rec.Name != "www" { + t.Fatalf("got name %q, want %q", rec.Name, "www") + } + if rec.Type != "A" { + t.Fatalf("got type %q, want %q", rec.Type, "A") + } +} + +func TestCreateRecordInvalidValue(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + _, err := client.CreateRecord(ctx, &pb.CreateRecordRequest{ + Zone: "example.com", + Name: "www", + Type: "A", + Value: "not-an-ip", + Ttl: 300, + }) + if err == nil { + t.Fatal("expected error for invalid A record value") + } + st, _ := status.FromError(err) + if st.Code() != codes.InvalidArgument { + t.Fatalf("code: got %v, want InvalidArgument", st.Code()) + } +} + +func TestCreateRecordCNAMEConflict(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + seedRecord(t, database, "example.com", "www", "10.0.0.1") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + // Try to create a CNAME for "www" which already has an A record. + _, err := client.CreateRecord(ctx, &pb.CreateRecordRequest{ + Zone: "example.com", + Name: "www", + Type: "CNAME", + Value: "other.example.com.", + Ttl: 300, + }) + if err == nil { + t.Fatal("expected error for CNAME conflict with existing A record") + } + st, _ := status.FromError(err) + if st.Code() != codes.AlreadyExists { + t.Fatalf("code: got %v, want AlreadyExists", st.Code()) + } +} + +func TestCreateRecordAConflictWithCNAME(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + + // Create a CNAME first. + _, err := database.CreateRecord("example.com", "alias", "CNAME", "target.example.com.", 300) + if err != nil { + t.Fatalf("seed CNAME: %v", err) + } + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + // Try to create an A record for "alias" which already has a CNAME. + _, err = client.CreateRecord(ctx, &pb.CreateRecordRequest{ + Zone: "example.com", + Name: "alias", + Type: "A", + Value: "10.0.0.1", + Ttl: 300, + }) + if err == nil { + t.Fatal("expected error for A record conflict with existing CNAME") + } + st, _ := status.FromError(err) + if st.Code() != codes.AlreadyExists { + t.Fatalf("code: got %v, want AlreadyExists", st.Code()) + } +} + +func TestUpdateRecord(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + rec := seedRecord(t, database, "example.com", "www", "10.0.0.1") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + updated, err := client.UpdateRecord(ctx, &pb.UpdateRecordRequest{ + Id: rec.ID, + Name: "www", + Type: "A", + Value: "10.0.0.2", + Ttl: 600, + }) + if err != nil { + t.Fatalf("UpdateRecord: %v", err) + } + if updated.Value != "10.0.0.2" { + t.Fatalf("got value %q, want %q", updated.Value, "10.0.0.2") + } + if updated.Ttl != 600 { + t.Fatalf("got ttl %d, want 600", updated.Ttl) + } +} + +func TestUpdateRecordNotFound(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + _, err := client.UpdateRecord(ctx, &pb.UpdateRecordRequest{ + Id: 999999, + Name: "www", + Type: "A", + Value: "10.0.0.1", + Ttl: 300, + }) + if err == nil { + t.Fatal("expected error for nonexistent record") + } + st, _ := status.FromError(err) + if st.Code() != codes.NotFound { + t.Fatalf("code: got %v, want NotFound", st.Code()) + } +} + +func TestDeleteRecord(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + seedZone(t, database, "example.com") + rec := seedRecord(t, database, "example.com", "www", "10.0.0.1") + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + _, err := client.DeleteRecord(ctx, &pb.DeleteRecordRequest{Id: rec.ID}) + if err != nil { + t.Fatalf("DeleteRecord: %v", err) + } + + // Verify it is gone. + _, err = client.GetRecord(withAuth(context.Background(), "user-token"), &pb.GetRecordRequest{Id: rec.ID}) + if err == nil { + t.Fatal("expected NotFound after delete") + } + st, _ := status.FromError(err) + if st.Code() != codes.NotFound { + t.Fatalf("code: got %v, want NotFound", st.Code()) + } +} + +func TestDeleteRecordNotFound(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewRecordServiceClient(cc) + + _, err := client.DeleteRecord(ctx, &pb.DeleteRecordRequest{Id: 999999}) + if err == nil { + t.Fatal("expected error for nonexistent record") + } + st, _ := status.FromError(err) + if st.Code() != codes.NotFound { + t.Fatalf("code: got %v, want NotFound", st.Code()) + } +} + +// --------------------------------------------------------------------------- +// Auth interceptor tests +// --------------------------------------------------------------------------- + +func TestAuthRequiredNoToken(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + client := pb.NewZoneServiceClient(cc) + + // No auth token on an auth-required method. + _, err := client.ListZones(context.Background(), &pb.ListZonesRequest{}) + if err == nil { + t.Fatal("expected error for unauthenticated request") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got %v", err) + } + if st.Code() != codes.Unauthenticated { + t.Fatalf("code: got %v, want Unauthenticated", st.Code()) + } +} + +func TestAuthRequiredInvalidToken(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "bad-token") + client := pb.NewZoneServiceClient(cc) + + _, err := client.ListZones(ctx, &pb.ListZonesRequest{}) + if err == nil { + t.Fatal("expected error for invalid token") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got %v", err) + } + if st.Code() != codes.Unauthenticated { + t.Fatalf("code: got %v, want Unauthenticated", st.Code()) + } +} + +func TestAdminRequiredDeniedForUser(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "user-token") + client := pb.NewZoneServiceClient(cc) + + // CreateZone requires admin. + _, err := client.CreateZone(ctx, &pb.CreateZoneRequest{ + Name: "forbidden.com", + PrimaryNs: "ns1.forbidden.com.", + AdminEmail: "admin.forbidden.com.", + }) + if err == nil { + t.Fatal("expected error for non-admin user") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("expected gRPC status error, got %v", err) + } + if st.Code() != codes.PermissionDenied { + t.Fatalf("code: got %v, want PermissionDenied", st.Code()) + } +} + +func TestAdminRequiredAllowedForAdmin(t *testing.T) { + mcias := mockMCIAS(t) + auth := testAuthenticator(t, mcias.URL) + database := openTestDB(t) + + cc := startTestServer(t, Deps{DB: database, Authenticator: auth}) + ctx := withAuth(context.Background(), "admin-token") + client := pb.NewZoneServiceClient(cc) + + // Admin should be able to create zones. + zone, err := client.CreateZone(ctx, &pb.CreateZoneRequest{ + Name: "admin-created.com", + PrimaryNs: "ns1.admin-created.com.", + AdminEmail: "admin.admin-created.com.", + }) + if err != nil { + t.Fatalf("CreateZone as admin: %v", err) + } + if zone.Name != "admin-created.com" { + t.Fatalf("got name %q, want %q", zone.Name, "admin-created.com") + } +} + +// --------------------------------------------------------------------------- +// Interceptor map completeness test +// --------------------------------------------------------------------------- + +func TestMethodMapCompleteness(t *testing.T) { + mm := methodMap() + + expectedPublic := []string{ + "/mcns.v1.AdminService/Health", + "/mcns.v1.AuthService/Login", + } + for _, method := range expectedPublic { + if !mm.Public[method] { + t.Errorf("method %s should be public but is not in Public", method) + } + } + if len(mm.Public) != len(expectedPublic) { + t.Errorf("Public has %d entries, expected %d", len(mm.Public), len(expectedPublic)) + } + + expectedAuth := []string{ + "/mcns.v1.AuthService/Logout", + "/mcns.v1.ZoneService/ListZones", + "/mcns.v1.ZoneService/GetZone", + "/mcns.v1.RecordService/ListRecords", + "/mcns.v1.RecordService/GetRecord", + } + for _, method := range expectedAuth { + if !mm.AuthRequired[method] { + t.Errorf("method %s should require auth but is not in AuthRequired", method) + } + } + if len(mm.AuthRequired) != len(expectedAuth) { + t.Errorf("AuthRequired has %d entries, expected %d", len(mm.AuthRequired), len(expectedAuth)) + } + + expectedAdmin := []string{ + "/mcns.v1.ZoneService/CreateZone", + "/mcns.v1.ZoneService/UpdateZone", + "/mcns.v1.ZoneService/DeleteZone", + "/mcns.v1.RecordService/CreateRecord", + "/mcns.v1.RecordService/UpdateRecord", + "/mcns.v1.RecordService/DeleteRecord", + } + for _, method := range expectedAdmin { + if !mm.AdminRequired[method] { + t.Errorf("method %s should require admin but is not in AdminRequired", method) + } + } + if len(mm.AdminRequired) != len(expectedAdmin) { + t.Errorf("AdminRequired has %d entries, expected %d", len(mm.AdminRequired), len(expectedAdmin)) + } + + // Verify no method appears in multiple maps (each RPC in exactly one map). + all := make(map[string]int) + for k := range mm.Public { + all[k]++ + } + for k := range mm.AuthRequired { + all[k]++ + } + for k := range mm.AdminRequired { + all[k]++ + } + for method, count := range all { + if count != 1 { + t.Errorf("method %s appears in %d maps, expected exactly 1", method, count) + } + } +}