From c3de9c1237deec7549ae757c3ef0170193b86291 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:55 +0200 Subject: [PATCH 01/25] feat(bssci): BSSCI 1.1 version negotiation and full unsigned EUI-64 range Both defects Oliver Haala (Fraunhofer IIS) reported when his Fraunhofer AVA base station could not connect. Version negotiation (CHIRP-3539): a station requesting 1.1.0 in con was answered with error 71 and disconnected. The service center now selects a compatible version and returns it in the conRsp version field, so the station accepts with conCmp or declines with error (BSSCI rev1 4.2, 5.3.2). A different major version remains a protocol error. Full unsigned EUI-64 (CHIRP-3540): 0xCAFECAFECAFECAFE was rejected because decoding narrowed uint64 to int64, the outbound scEui was corrupted through its JSON projection, and values above 2^53 lost precision in browser transport. The complete range now survives wire decode and encode, persistence, gRPC, certificates and configuration. Includes the framed interop matrix that replays the Fraunhofer scenario exactly, and the EUI-64 database matrix covering constraint saturation. --- KC-Core/api/gen/kilocenter/v1/core.pb.go | 62 +- KC-Core/api/proto/core.proto | 8 +- KC-Core/cmd/certgen/main.go | 25 +- KC-Core/cmd/kilocenter/builders/core.go | 7 +- .../cmd/kilocenter/builders/infrastructure.go | 8 +- KC-Core/cmd/kilocenter/builders/protocol.go | 30 +- KC-Core/config.yaml | 1 + KC-Core/internal/grpc/bs_eui_resolver.go | 35 + KC-Core/internal/grpc/bs_eui_resolver_test.go | 232 ++++ KC-Core/internal/grpc/core_service.go | 67 +- .../internal/grpc/core_service_blueprints.go | 1 + KC-Core/internal/grpc/core_service_certs.go | 5 +- .../grpc/core_service_integrations.go | 32 +- KC-Core/internal/health/health.go | 4 +- .../internal/services/blueprints/service.go | 9 +- .../services/bssci/connection_service.go | 6 +- .../bssci/endpoint_attachment_service.go | 11 +- KC-Core/internal/services/bssci/factories.go | 12 +- .../internal/services/bssci/mqtt_adapter.go | 37 +- .../services/bssci/queue_serializer.go | 22 +- .../services/bssci/session_resume_db_test.go | 469 +++---- .../services/bssci/session_service.go | 356 ++--- .../services/bssci/session_service_test.go | 123 -- .../internal/services/bssci/test_helpers.go | 57 +- .../services/bssci/version_negotiator.go | 110 ++ .../services/bssci/version_negotiator_test.go | 178 +++ .../internal/services/certificates/service.go | 51 +- .../services/certificates/service_test.go | 69 + KC-Core/pkg/basestation/connection_manager.go | 43 +- KC-Core/pkg/basestation/repository_adapter.go | 45 +- KC-Core/pkg/bssci/connect_handler_test.go | 119 +- KC-Core/pkg/bssci/constants.go | 27 + KC-Core/pkg/bssci/contracts.go | 31 +- KC-Core/pkg/bssci/conversions.go | 47 + KC-Core/pkg/bssci/crypto.go | 6 +- KC-Core/pkg/bssci/detach_integration_test.go | 30 +- KC-Core/pkg/bssci/dl_rx_status_test.go | 5 +- KC-Core/pkg/bssci/downlink_handlers.go | 38 +- KC-Core/pkg/bssci/errors_catalog.go | 231 ++-- KC-Core/pkg/bssci/log_messages.go | 4 + .../bssci/mandatory_field_validation_test.go | 10 +- KC-Core/pkg/bssci/message_assembly_test.go | 154 +++ KC-Core/pkg/bssci/message_metadata.go | 18 + KC-Core/pkg/bssci/message_normalizer.go | 252 +++- KC-Core/pkg/bssci/outbound_validation_test.go | 20 +- KC-Core/pkg/bssci/phase_c2_regression_test.go | 4 +- KC-Core/pkg/bssci/protocol_edge_cases_test.go | 108 +- KC-Core/pkg/bssci/server.go | 1152 ++++++++--------- KC-Core/pkg/bssci/tenant_context_test.go | 8 +- KC-Core/pkg/bssci/test_helpers_test.go | 110 +- KC-Core/pkg/bssci/version_interop_test.go | 355 +++++ KC-Core/pkg/config/constants.go | 18 + KC-Core/pkg/config/loader.go | 56 + KC-Core/pkg/config/loader_test.go | 154 +++ KC-Core/pkg/config/messages.go | 7 + KC-Core/pkg/config/protocol.go | 13 + KC-Core/pkg/config/service_center.go | 12 + KC-Core/pkg/config/service_center_test.go | 40 +- KC-Core/pkg/grpc/errors_catalog.go | 17 +- KC-Core/pkg/grpc/interceptors/auth.go | 10 +- .../pkg/grpc/interceptors/authorization.go | 4 +- .../pkg/grpc/interceptors/internal_trust.go | 4 +- KC-Core/pkg/logger/logger.go | 11 +- KC-Core/pkg/management/bssci_manager.go | 26 +- KC-Core/pkg/mioty/format.go | 15 + KC-Core/pkg/mioty/format_test.go | 38 + .../000119_add_user_profile_fields.down.sql | 6 +- .../000139_restore_message_archival.down.sql | 47 + .../000139_restore_message_archival.up.sql | 36 + .../basestation_session_repository.go | 14 +- KC-DB/storage/mioty/types.go | 11 + KC-DB/storage/models/basestation_session.go | 15 +- KC-DB/storage/postgres/archival.go | 22 +- KC-DB/storage/postgres/archival_test.go | 170 +++ .../storage/postgres/archival_test.go.broken | 446 ------- .../postgres/basestation_repository_test.go | 114 +- .../basestation_session_repository.go | 96 +- .../storage/postgres/blueprint_repository.go | 6 +- .../postgres/device_model_repository.go | 6 +- .../downlink_result_security_test.go.broken | 384 ------ .../storage/postgres/downlink_result_test.go | 420 ++++++ .../postgres/downlink_result_test.go.broken | 355 ----- .../postgres/eui64_integration_test.go | 133 ++ .../storage/postgres/fixtures_test.go.broken | 541 -------- .../postgres/manufacturer_repository.go | 2 +- .../postgres/migration_discovery_test.go | 7 +- KC-DB/storage/postgres/migration_test.go | 14 +- KC-DB/storage/postgres/migrations_test.go | 9 +- .../postgres/organization_repository.go | 28 +- .../postgres/performance_test.go.broken | 482 ------- KC-DB/storage/postgres/schema_audit_test.go | 4 +- .../storage/postgres/session_encoding_test.go | 2 +- KC-DB/storage/postgres/system_events.go | 6 +- .../postgres/table_verification_test.go | 24 +- .../storage/postgres/transaction_blueprint.go | 6 +- .../postgres/transaction_device_model.go | 6 +- .../postgres/transaction_manufacturer.go | 2 +- .../queries/operation_status_queries.go | 2 +- KC-Gateway/cmd/gateway/main.go | 13 +- KC-Gateway/internal/health/handler.go | 10 +- .../internal/resilience/rate_limiter.go | 5 +- KC-Identity/cmd/identity/health.go | 16 +- KC-Identity/cmd/identity/main.go | 8 +- .../internal/grpc/identity_service_admin.go | 21 +- .../internal/grpc/identity_service_auth.go | 6 +- KC-Web/src/components/common/SearchField.tsx | 4 +- KC-Web/src/hooks/useRealtime.ts | 9 +- KC-Web/src/mappers/base-station.mapper.ts | 5 +- .../components/BaseStationCertRegenDialog.tsx | 6 +- .../BaseStationCommissioningDialog.tsx | 6 +- .../components/BaseStationDetails.tsx | 5 +- .../components/BaseStationEditDialog.tsx | 20 +- .../components/BaseStationInfoPanel.tsx | 8 +- .../components/BaseStationLocationFields.tsx | 4 +- .../base-stations/pages/BaseStations.tsx | 5 +- .../modules/blueprints/hooks/useBlueprints.ts | 8 +- .../blueprints/hooks/useDeviceModels.ts | 8 +- .../components/AddEndPointDialog.tsx | 5 +- .../endpoints/components/DownlinkTab.tsx | 11 +- .../components/EditEndPointDialog.tsx | 4 +- .../endpoints/components/EndPointDetails.tsx | 15 +- .../components/EndpointFormFields.tsx | 4 +- .../components/EndpointSettingsSection.tsx | 4 +- .../src/modules/endpoints/pages/EndPoints.tsx | 20 +- .../pages/OrganizationDetail.tsx | 4 +- KC-Web/src/services/api.ts | 4 +- KC-Web/src/services/grpc/client.ts | 36 +- KC-Web/src/services/grpc/core_pb.d.ts | 8 + KC-Web/src/services/grpc/core_pb.js | 64 +- 129 files changed, 4806 insertions(+), 4225 deletions(-) create mode 100644 KC-Core/internal/grpc/bs_eui_resolver.go create mode 100644 KC-Core/internal/grpc/bs_eui_resolver_test.go create mode 100644 KC-Core/internal/services/bssci/version_negotiator.go create mode 100644 KC-Core/internal/services/bssci/version_negotiator_test.go create mode 100644 KC-Core/pkg/bssci/version_interop_test.go create mode 100644 KC-Core/pkg/mioty/format_test.go create mode 100644 KC-DB/migrations/000139_restore_message_archival.down.sql create mode 100644 KC-DB/migrations/000139_restore_message_archival.up.sql create mode 100644 KC-DB/storage/postgres/archival_test.go delete mode 100644 KC-DB/storage/postgres/archival_test.go.broken delete mode 100644 KC-DB/storage/postgres/downlink_result_security_test.go.broken create mode 100644 KC-DB/storage/postgres/downlink_result_test.go delete mode 100644 KC-DB/storage/postgres/downlink_result_test.go.broken create mode 100644 KC-DB/storage/postgres/eui64_integration_test.go delete mode 100644 KC-DB/storage/postgres/fixtures_test.go.broken delete mode 100644 KC-DB/storage/postgres/performance_test.go.broken diff --git a/KC-Core/api/gen/kilocenter/v1/core.pb.go b/KC-Core/api/gen/kilocenter/v1/core.pb.go index b91e791..30353e8 100644 --- a/KC-Core/api/gen/kilocenter/v1/core.pb.go +++ b/KC-Core/api/gen/kilocenter/v1/core.pb.go @@ -54,8 +54,8 @@ type EndPoint struct { TypeEui []byte `protobuf:"bytes,22,opt,name=type_eui,json=typeEui,proto3" json:"type_eui,omitempty"` // 8-byte Type EUI for blueprint decoding CarrierOffset int32 `protobuf:"varint,23,opt,name=carrier_offset,json=carrierOffset,proto3" json:"carrier_offset,omitempty"` // Carrier offset in Hz DeviceModelId string `protobuf:"bytes,24,opt,name=device_model_id,json=deviceModelId,proto3" json:"device_model_id,omitempty"` // UUID reference to device_models.id for blueprint decoding - BlueprintId string `protobuf:"bytes,25,opt,name=blueprint_id,json=blueprintId,proto3" json:"blueprint_id,omitempty"` // Input selector: blueprint chosen for this endpoint; KC materializes its spec into blueprint_snapshot. Not a stored FK. On read echoes snapshot.source_blueprint_id. - BlueprintSnapshot []byte `protobuf:"bytes,26,opt,name=blueprint_snapshot,json=blueprintSnapshot,proto3" json:"blueprint_snapshot,omitempty"` // Read-only: materialized snapshot JSON (spec + provenance); empty = follows catalog default. + BlueprintId string `protobuf:"bytes,25,opt,name=blueprint_id,json=blueprintId,proto3" json:"blueprint_id,omitempty"` // Input selector (not a stored FK); materialized into blueprint_snapshot, echoed on read. + BlueprintSnapshot []byte `protobuf:"bytes,26,opt,name=blueprint_snapshot,json=blueprintSnapshot,proto3" json:"blueprint_snapshot,omitempty"` // Read-only materialized snapshot; empty = follows catalog default. unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3353,8 +3353,12 @@ func (x *SendULTransmitResponse) GetMessage() string { // Base Station Status Request (BSSCI 3.5) type BaseStationStatusRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - BsEui uint64 `protobuf:"varint,1,opt,name=bs_eui,json=bsEui,proto3" json:"bs_eui,omitempty"` // Base Station EUI + state protoimpl.MessageState `protogen:"open.v1"` + // Deprecated: uint64 loses precision above 2^53 in browser JavaScript clients; use bs_eui_hex. + // + // Deprecated: Marked as deprecated in core.proto. + BsEui uint64 `protobuf:"varint,1,opt,name=bs_eui,json=bsEui,proto3" json:"bs_eui,omitempty"` + BsEuiHex string `protobuf:"bytes,2,opt,name=bs_eui_hex,json=bsEuiHex,proto3" json:"bs_eui_hex,omitempty"` // Base Station EUI as hex string (browser-safe full-range representation; accepts dashed or non-dashed) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3389,6 +3393,7 @@ func (*BaseStationStatusRequest) Descriptor() ([]byte, []int) { return file_core_proto_rawDescGZIP(), []int{40} } +// Deprecated: Marked as deprecated in core.proto. func (x *BaseStationStatusRequest) GetBsEui() uint64 { if x != nil { return x.BsEui @@ -3396,6 +3401,13 @@ func (x *BaseStationStatusRequest) GetBsEui() uint64 { return 0 } +func (x *BaseStationStatusRequest) GetBsEuiHex() string { + if x != nil { + return x.BsEuiHex + } + return "" +} + type BaseStationStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the request was sent successfully @@ -3458,8 +3470,12 @@ func (x *BaseStationStatusResponse) GetOpId() int64 { // Ping Request (BSSCI §5.4) type InitiatePingRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - BsEui uint64 `protobuf:"varint,1,opt,name=bs_eui,json=bsEui,proto3" json:"bs_eui,omitempty"` // Base Station EUI + state protoimpl.MessageState `protogen:"open.v1"` + // Deprecated: uint64 loses precision above 2^53 in browser JavaScript clients; use bs_eui_hex. + // + // Deprecated: Marked as deprecated in core.proto. + BsEui uint64 `protobuf:"varint,1,opt,name=bs_eui,json=bsEui,proto3" json:"bs_eui,omitempty"` + BsEuiHex string `protobuf:"bytes,2,opt,name=bs_eui_hex,json=bsEuiHex,proto3" json:"bs_eui_hex,omitempty"` // Base Station EUI as hex string (browser-safe full-range representation; accepts dashed or non-dashed) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3494,6 +3510,7 @@ func (*InitiatePingRequest) Descriptor() ([]byte, []int) { return file_core_proto_rawDescGZIP(), []int{42} } +// Deprecated: Marked as deprecated in core.proto. func (x *InitiatePingRequest) GetBsEui() uint64 { if x != nil { return x.BsEui @@ -3501,6 +3518,13 @@ func (x *InitiatePingRequest) GetBsEui() uint64 { return 0 } +func (x *InitiatePingRequest) GetBsEuiHex() string { + if x != nil { + return x.BsEuiHex + } + return "" +} + type InitiatePingResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether ping was sent successfully @@ -9016,7 +9040,7 @@ type CreateManufacturerRequest struct { Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` Website string `protobuf:"bytes,4,opt,name=website,proto3" json:"website,omitempty"` ContactEmail string `protobuf:"bytes,5,opt,name=contact_email,json=contactEmail,proto3" json:"contact_email,omitempty"` - IsSystem bool `protobuf:"varint,6,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System row; rejected for non-admins + IsSystem bool `protobuf:"varint,6,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System row unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9564,7 +9588,7 @@ type Manufacturer struct { CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // Fields from KC-DB/storage/models/manufacturer.go - TenantId string `protobuf:"bytes,9,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` // string per proto convention (line 198); empty when is_system + TenantId string `protobuf:"bytes,9,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` // string per proto convention; empty when is_system IsVerified bool `protobuf:"varint,10,opt,name=is_verified,json=isVerified,proto3" json:"is_verified,omitempty"` // From manufacturer.go:19 ModelCount int32 `protobuf:"varint,11,opt,name=model_count,json=modelCount,proto3" json:"model_count,omitempty"` // From manufacturer.go:24 IsSystem bool `protobuf:"varint,12,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // true = System catalog row, false = tenant Custom @@ -9693,7 +9717,7 @@ type CreateDeviceModelRequest struct { Code string `protobuf:"bytes,3,opt,name=code,proto3" json:"code,omitempty"` TypeEui string `protobuf:"bytes,4,opt,name=type_eui,json=typeEui,proto3" json:"type_eui,omitempty"` // Hex (16 chars) Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` - IsSystem bool `protobuf:"varint,6,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System row; rejected for non-admins + IsSystem bool `protobuf:"varint,6,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System row unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10363,7 +10387,7 @@ type CreateBlueprintRequest struct { Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` DecoderScript []byte `protobuf:"bytes,5,opt,name=decoder_script,json=decoderScript,proto3" json:"decoder_script,omitempty"` // JavaScript decoder IsDefault bool `protobuf:"varint,6,opt,name=is_default,json=isDefault,proto3" json:"is_default,omitempty"` - IsSystem bool `protobuf:"varint,7,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System row; rejected for non-admins + IsSystem bool `protobuf:"varint,7,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System row unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -11432,7 +11456,7 @@ type CreateDeviceModelWithBlueprintRequest struct { Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // Model name Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` // Blueprint version (e.g. "1.0.0") DecoderScript []byte `protobuf:"bytes,4,opt,name=decoder_script,json=decoderScript,proto3" json:"decoder_script,omitempty"` // Blueprint specification JSON - IsSystem bool `protobuf:"varint,5,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System rows; rejected for non-admins + IsSystem bool `protobuf:"varint,5,opt,name=is_system,json=isSystem,proto3" json:"is_system,omitempty"` // admin-only System rows unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -14446,15 +14470,19 @@ const file_core_proto_rawDesc = "" + "\x16SendULTransmitResponse\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x18\n" + - "\amessage\x18\x03 \x01(\tR\amessage\"1\n" + - "\x18BaseStationStatusRequest\x12\x15\n" + - "\x06bs_eui\x18\x01 \x01(\x04R\x05bsEui\"d\n" + + "\amessage\x18\x03 \x01(\tR\amessage\"S\n" + + "\x18BaseStationStatusRequest\x12\x19\n" + + "\x06bs_eui\x18\x01 \x01(\x04B\x02\x18\x01R\x05bsEui\x12\x1c\n" + + "\n" + + "bs_eui_hex\x18\x02 \x01(\tR\bbsEuiHex\"d\n" + "\x19BaseStationStatusResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x13\n" + - "\x05op_id\x18\x03 \x01(\x03R\x04opId\",\n" + - "\x13InitiatePingRequest\x12\x15\n" + - "\x06bs_eui\x18\x01 \x01(\x04R\x05bsEui\"_\n" + + "\x05op_id\x18\x03 \x01(\x03R\x04opId\"N\n" + + "\x13InitiatePingRequest\x12\x19\n" + + "\x06bs_eui\x18\x01 \x01(\x04B\x02\x18\x01R\x05bsEui\x12\x1c\n" + + "\n" + + "bs_eui_hex\x18\x02 \x01(\tR\bbsEuiHex\"_\n" + "\x14InitiatePingResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x13\n" + diff --git a/KC-Core/api/proto/core.proto b/KC-Core/api/proto/core.proto index 1a7d601..c60e214 100644 --- a/KC-Core/api/proto/core.proto +++ b/KC-Core/api/proto/core.proto @@ -573,7 +573,9 @@ message SendULTransmitResponse { // Base Station Status Request (BSSCI 3.5) message BaseStationStatusRequest { - uint64 bs_eui = 1; // Base Station EUI + // Deprecated: uint64 loses precision above 2^53 in browser JavaScript clients; use bs_eui_hex. + uint64 bs_eui = 1 [deprecated = true]; + string bs_eui_hex = 2; // Base Station EUI as hex string (browser-safe full-range representation; accepts dashed or non-dashed) } message BaseStationStatusResponse { @@ -584,7 +586,9 @@ message BaseStationStatusResponse { // Ping Request (BSSCI §5.4) message InitiatePingRequest { - uint64 bs_eui = 1; // Base Station EUI + // Deprecated: uint64 loses precision above 2^53 in browser JavaScript clients; use bs_eui_hex. + uint64 bs_eui = 1 [deprecated = true]; + string bs_eui_hex = 2; // Base Station EUI as hex string (browser-safe full-range representation; accepts dashed or non-dashed) } message InitiatePingResponse { diff --git a/KC-Core/cmd/certgen/main.go b/KC-Core/cmd/certgen/main.go index 4fbe6e3..4c24ab2 100644 --- a/KC-Core/cmd/certgen/main.go +++ b/KC-Core/cmd/certgen/main.go @@ -18,6 +18,13 @@ import ( "time" ) +// Certificate subject identity shared by the CA and issued certificates. +const ( + certSubjectOrganization = "KiloCenter" + certSubjectProvince = "California" + certSubjectLocality = "San Francisco" +) + func main() { var ( certDir = flag.String("dir", "certs", "Directory to store certificates") @@ -151,11 +158,11 @@ func generateCA(validYears int) (*x509.Certificate, *rsa.PrivateKey, error) { template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ - Organization: []string{"KiloCenter"}, + Organization: []string{certSubjectOrganization}, OrganizationalUnit: []string{"MIOTY Certificate Authority"}, Country: []string{"US"}, - Province: []string{"California"}, - Locality: []string{"San Francisco"}, + Province: []string{certSubjectProvince}, + Locality: []string{certSubjectLocality}, CommonName: "KiloCenter Root CA", }, NotBefore: time.Now(), @@ -197,11 +204,11 @@ func generateClientCert(caCert *x509.Certificate, caKey *rsa.PrivateKey, clientN template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ - Organization: []string{"KiloCenter"}, + Organization: []string{certSubjectOrganization}, OrganizationalUnit: []string{"MIOTY Base Station"}, Country: []string{"US"}, - Province: []string{"California"}, - Locality: []string{"San Francisco"}, + Province: []string{certSubjectProvince}, + Locality: []string{certSubjectLocality}, CommonName: clientName, }, NotBefore: time.Now(), @@ -236,11 +243,11 @@ func generateServerCert(caCert *x509.Certificate, caKey *rsa.PrivateKey, serverN template := x509.Certificate{ SerialNumber: big.NewInt(2), Subject: pkix.Name{ - Organization: []string{"KiloCenter"}, + Organization: []string{certSubjectOrganization}, OrganizationalUnit: []string{"MIOTY Service Center"}, Country: []string{"US"}, - Province: []string{"California"}, - Locality: []string{"San Francisco"}, + Province: []string{certSubjectProvince}, + Locality: []string{certSubjectLocality}, CommonName: serverName, }, NotBefore: time.Now(), diff --git a/KC-Core/cmd/kilocenter/builders/core.go b/KC-Core/cmd/kilocenter/builders/core.go index bd67233..b7ac81a 100644 --- a/KC-Core/cmd/kilocenter/builders/core.go +++ b/KC-Core/cmd/kilocenter/builders/core.go @@ -28,6 +28,9 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" ) +// coreServiceSourceName identifies KC-Core as the source of system events. +const coreServiceSourceName = "kc-core" + // CoreResult holds the fully wired CoreService and shared system event adapter. type CoreResult struct { Service *grpc.CoreService @@ -109,7 +112,7 @@ func BuildCoreService(ctx context.Context, infra *Infrastructure, protocol *Prot listenInfo := fmt.Sprintf("gRPC=%d, BSSCI=%d, SCACI=%d", cfg.GRPC.Port, cfg.Protocol.BSCIPort, cfg.Protocol.SCACIPort) startDetails, _ := json.Marshal(map[string]interface{}{ - "service": "kc-core", + "service": coreServiceSourceName, "version": infra.VersionInfo.Version, "gitCommit": infra.VersionInfo.GitCommit, "host": hostname, @@ -127,7 +130,7 @@ func BuildCoreService(ctx context.Context, infra *Infrastructure, protocol *Prot Title: fmt.Sprintf(models.EventTitleServiceStartedFmt, "KC-Core", hostname), Description: fmt.Sprintf(models.EventDescriptionServiceStartedFmt, "KC-Core", infra.VersionInfo.Version, listenInfo), SourceType: models.SourceTypeSystem, - SourceName: "kc-core", + SourceName: coreServiceSourceName, Details: json.RawMessage(startDetails), CreatedAt: time.Now(), UpdatedAt: time.Now(), diff --git a/KC-Core/cmd/kilocenter/builders/infrastructure.go b/KC-Core/cmd/kilocenter/builders/infrastructure.go index bac8732..fe982ce 100644 --- a/KC-Core/cmd/kilocenter/builders/infrastructure.go +++ b/KC-Core/cmd/kilocenter/builders/infrastructure.go @@ -118,8 +118,8 @@ func BuildInfrastructure(ctx context.Context, cfg *pkgconfig.Config, versionInfo Category: models.EventCategoryError, Severity: models.EventSeverityError, Title: models.EventTitleMigrationFailed, - Description: fmt.Sprintf(models.EventDescriptionMigrationFailedFmt, "kc-core", migErr.Error()), - SourceName: "kc-core", + Description: fmt.Sprintf(models.EventDescriptionMigrationFailedFmt, coreServiceSourceName, migErr.Error()), + SourceName: coreServiceSourceName, SourceType: models.SourceTypeSystem, }); evtErr != nil { log.Error("failed to emit migration.failed event", logger.Err(evtErr)) @@ -134,8 +134,8 @@ func BuildInfrastructure(ctx context.Context, cfg *pkgconfig.Config, versionInfo Category: models.EventCategorySystem, Severity: models.EventSeverityInfo, Title: models.EventTitleMigrationApplied, - Description: fmt.Sprintf(models.EventDescriptionMigrationAppliedFmt, dbVersion, "kc-core"), - SourceName: "kc-core", + Description: fmt.Sprintf(models.EventDescriptionMigrationAppliedFmt, dbVersion, coreServiceSourceName), + SourceName: coreServiceSourceName, SourceType: models.SourceTypeSystem, }); evtErr != nil { log.Error("failed to emit migration.applied event", logger.Err(evtErr)) diff --git a/KC-Core/cmd/kilocenter/builders/protocol.go b/KC-Core/cmd/kilocenter/builders/protocol.go index 118e3a4..9010f15 100644 --- a/KC-Core/cmd/kilocenter/builders/protocol.go +++ b/KC-Core/cmd/kilocenter/builders/protocol.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "os" - "strconv" "sync" "time" @@ -18,6 +16,7 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/scaci" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/postgres" "github.com/Kiloiot/kilo-service-center/KC-MQTT/pkg/mqtt" ) @@ -49,15 +48,10 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol log.Info("Initializing mandatory BSSCI server...") - // Service Center EUI - can be overridden by environment variable - serviceCenterEUI := uint64(0x4B43000000000001) // Default: "KC" prefix + unique ID - if scEUIStr := os.Getenv("SERVICE_CENTER_EUI"); scEUIStr != "" { - if parsed, err := strconv.ParseUint(scEUIStr, 0, 64); err == nil { - serviceCenterEUI = parsed - log.Info("Using Service Center EUI from environment", "eui", fmt.Sprintf("0x%016X", serviceCenterEUI)) - } else { - log.Info("Invalid SERVICE_CENTER_EUI format, using default", "eui", fmt.Sprintf("0x%016X", serviceCenterEUI)) - } + // Service Center EUI is resolved and validated during config load (pkg/config Load) + serviceCenterEUI := cfg.Protocol.SCEUIValue + if cfg.Protocol.SCEUILegacyEnvUsed { + log.WarnContext(ctx, pkgconfig.LogDeprecatedServiceCenterEUIEnv, "sc_eui", cfg.Protocol.SCEUI) } // Resolve software version from release manifest with config fallback @@ -79,13 +73,16 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol TLSCACert: cfg.Protocol.BSCITLS.CAFile, TLSMinVersion: cfg.Protocol.BSCITLS.MinVersion, ServiceCenterEUI: serviceCenterEUI, - Vendor: "Kilo", - Model: "KiloCenter", + Vendor: cfg.Protocol.SCVendor, + Model: cfg.Protocol.SCModel, Name: cfg.General.ServerName, SoftwareVersion: softwareVersion, OrgEnforcementEnabled: cfg.General.OrgEnforcementEnabled, MessageEncoding: cfg.Protocol.MessageEncoding, DetachSignatureValidationEnabled: cfg.Protocol.DetachSignatureValidationEnabled, + OperationAckTimeout: time.Duration(cfg.Protocol.AckTimeout) * time.Millisecond, + DuplicateWindow: time.Duration(cfg.Protocol.DuplicateWindow) * time.Second, + CertificatePollInterval: cfg.Protocol.BSCICertificatePollInterval, } // Create BSSCI service bundles @@ -95,7 +92,7 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol pendingOps := make(map[bssci.SessionOpKey]*bssci.PendingOperation) var pendingOpsMu sync.RWMutex - bssciSvcBundle := bssciservices.NewBSSCIServices( + bssciSvcBundle, err := bssciservices.NewBSSCIServices( infra.Storage, infra.SystemEventStore, infra.QueueStore, @@ -104,7 +101,11 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol infra.OrgResolverSvc, &pendingOps, &pendingOpsMu, + []string{mioty.MIOTYProtocolVersion}, ) + if err != nil { + return nil, fmt.Errorf("failed to build BSSCI services: %w", err) + } bssciInfra := &bssciservices.BSSCIInfrastructure{ ConnectionMgr: infra.ConnectionMgr, @@ -129,6 +130,7 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol bssciInfra.EndpointRepo, infra.TenantID, bssciSvcBundle.SessionSvc, + bssciSvcBundle.VersionNegotiator, bssciSvcBundle.DownlinkSvc, bssciSvcBundle.StatusSvc, bssciSvcBundle.ConnectionSvc, diff --git a/KC-Core/config.yaml b/KC-Core/config.yaml index 045a96b..9e6bb0d 100644 --- a/KC-Core/config.yaml +++ b/KC-Core/config.yaml @@ -110,6 +110,7 @@ protocol: # Service Center Identity (DEFER-007) - exposed via gRPC GetReleaseInfo sc_vendor: "Kilo" # Vendor name (displayed to operators) sc_model: "KiloCenter" # Model name (Community or Enterprise SKU) + # sc_eui: "4B43000000000001" # Service Center EUI (16 hex chars; env override: KILOCENTER_PROTOCOL_SC_EUI) grpc: port: 50051 # Internal only (gateway owns :9090) diff --git a/KC-Core/internal/grpc/bs_eui_resolver.go b/KC-Core/internal/grpc/bs_eui_resolver.go new file mode 100644 index 0000000..8c3982c --- /dev/null +++ b/KC-Core/internal/grpc/bs_eui_resolver.go @@ -0,0 +1,35 @@ +package grpc + +import ( + "google.golang.org/grpc/status" + + grpcerrors "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" + "github.com/Kiloiot/kilo-service-center/KC-DB/common/validation" +) + +// resolveBaseStationEUI resolves the target base station EUI from a request that +// carries both the browser-safe hex field and the deprecated uint64 field. +// The hex form exists because JavaScript clients cannot represent EUIs above +// 2^53 as numbers; when both fields are set they must refer to the same EUI. +func resolveBaseStationEUI(bsEuiHex string, legacyBsEui uint64) (uint64, error) { + if bsEuiHex == "" { + if legacyBsEui == 0 { + return 0, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenBasestationEUIRequired), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBasestationEUIRequired)) + } + return legacyBsEui, nil + } + + parsed, err := validation.ParseEUI(bsEuiHex) + if err != nil { + return 0, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenInvalidBasestationEUIFormat), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenInvalidBasestationEUIFormat)) + } + + if legacyBsEui != 0 && legacyBsEui != parsed { + return 0, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenBaseStationEUIMismatch), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBaseStationEUIMismatch)) + } + + return parsed, nil +} diff --git a/KC-Core/internal/grpc/bs_eui_resolver_test.go b/KC-Core/internal/grpc/bs_eui_resolver_test.go new file mode 100644 index 0000000..842426e --- /dev/null +++ b/KC-Core/internal/grpc/bs_eui_resolver_test.go @@ -0,0 +1,232 @@ +package grpc + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + pb "github.com/Kiloiot/kilo-service-center/KC-Core/api/gen/kilocenter/v1" + grpcerrors "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" +) + +// highBitEUI exceeds 2^53 and cannot round-trip through a JavaScript Number. +const highBitEUI uint64 = 0xCAFECAFECAFECAFE + +func TestResolveBaseStationEUI(t *testing.T) { + tests := []struct { + name string + bsEuiHex string + legacyBsEui uint64 + want uint64 + wantErrMsg string + }{ + { + name: "legacy numeric only", + bsEuiHex: "", + legacyBsEui: 0x70B3D59CD00009E6, + want: 0x70B3D59CD00009E6, + }, + { + name: "hex only", + bsEuiHex: "CAFECAFECAFECAFE", + want: highBitEUI, + }, + { + name: "hex only dashed", + bsEuiHex: "CA-FE-CA-FE-CA-FE-CA-FE", + want: highBitEUI, + }, + { + name: "both present and equal", + bsEuiHex: "CAFECAFECAFECAFE", + legacyBsEui: highBitEUI, + want: highBitEUI, + }, + { + name: "both present and mismatched", + bsEuiHex: "CAFECAFECAFECAFE", + legacyBsEui: 0x70B3D59CD00009E6, + wantErrMsg: grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBaseStationEUIMismatch), + }, + { + name: "missing both", + bsEuiHex: "", + wantErrMsg: grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBasestationEUIRequired), + }, + { + name: "malformed hex", + bsEuiHex: "ZZZZCAFECAFECAFE", + wantErrMsg: grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenInvalidBasestationEUIFormat), + }, + { + name: "hex too short", + bsEuiHex: "CAFE", + wantErrMsg: grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenInvalidBasestationEUIFormat), + }, + { + name: "malformed hex with legacy fallback still rejected", + bsEuiHex: "not-a-eui", + legacyBsEui: 0x70B3D59CD00009E6, + wantErrMsg: grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenInvalidBasestationEUIFormat), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveBaseStationEUI(tt.bsEuiHex, tt.legacyBsEui) + if tt.wantErrMsg != "" { + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok, "error must be a gRPC status") + assert.Equal(t, codes.InvalidArgument, st.Code()) + assert.Equal(t, tt.wantErrMsg, st.Message()) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +// euiSessionDir returns a non-nil session object for a single expected EUI. +type euiSessionDir struct { + expectedEUI uint64 +} + +func (d *euiSessionDir) GetConnectedSessions() []map[string]interface{} { return nil } +func (d *euiSessionDir) GetSessionByEUI(eui uint64) interface{} { + if eui == d.expectedEUI { + return struct{}{} + } + return nil +} +func (d *euiSessionDir) SelectBidirectionalSession(_ int64, _ *uint64) (string, uint64, error) { + return "", 0, nil +} +func (d *euiSessionDir) FindSessionForEndpointAttachment(_ uint64) (string, error) { + return "", nil +} + +// capturingStatusReq records that a status request was sent. +type capturingStatusReq struct { + called bool + opID int64 +} + +func (r *capturingStatusReq) SendStatusRequest(_ interface{}) (int64, error) { + r.called = true + return r.opID, nil +} + +// capturingPingCmd records the EUI passed to InitiatePing. +type capturingPingCmd struct { + gotEUI uint64 + opID int64 +} + +func (c *capturingPingCmd) InitiatePing(_ context.Context, bsEui uint64, _ int64) (int64, error) { + c.gotEUI = bsEui + return c.opID, nil +} + +func highBitEUIBytes() []byte { + return []byte{0xCA, 0xFE, 0xCA, 0xFE, 0xCA, 0xFE, 0xCA, 0xFE} +} + +func TestRequestBaseStationStatus_AcceptsBsEuiHex(t *testing.T) { + var capturedEUIBytes []byte + bsSvc := &mockBasestationSvc{ + getByEUIFunc: func(_ context.Context, eui []byte, _ int64) (*models.BaseStation, error) { + capturedEUIBytes = eui + return &models.BaseStation{Name: "high-bit station"}, nil + }, + } + statusReq := &capturingStatusReq{opID: 42} + svc := &CoreService{ + basestationSvc: bsSvc, + sessionDir: &euiSessionDir{expectedEUI: highBitEUI}, + statusReq: statusReq, + log: &mockLogger{}, + } + + ctx := testutil.TestContextWithTenant(1) + resp, err := svc.RequestBaseStationStatus(ctx, &pb.BaseStationStatusRequest{ + BsEuiHex: "CAFECAFECAFECAFE", + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.Success) + assert.Equal(t, int64(42), resp.OpId) + assert.True(t, statusReq.called, "status request must be sent to the resolved session") + assert.True(t, bytes.Equal(highBitEUIBytes(), capturedEUIBytes), + "ownership check must receive big-endian bytes of the hex EUI") +} + +func TestRequestBaseStationStatus_RejectsMissingEUI(t *testing.T) { + svc := &CoreService{ + basestationSvc: &mockBasestationSvc{}, + sessionDir: &euiSessionDir{}, + statusReq: &capturingStatusReq{}, + log: &mockLogger{}, + } + + ctx := testutil.TestContextWithTenant(1) + _, err := svc.RequestBaseStationStatus(ctx, &pb.BaseStationStatusRequest{}) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) +} + +func TestInitiatePing_AcceptsBsEuiHex(t *testing.T) { + var capturedEUIBytes []byte + bsSvc := &mockBasestationSvc{ + getByEUIFunc: func(_ context.Context, eui []byte, _ int64) (*models.BaseStation, error) { + capturedEUIBytes = eui + return &models.BaseStation{Name: "high-bit station"}, nil + }, + } + pingCmd := &capturingPingCmd{opID: 7} + svc := &CoreService{ + basestationSvc: bsSvc, + pingCmd: pingCmd, + log: &mockLogger{}, + } + + ctx := testutil.TestContextWithTenant(1) + resp, err := svc.InitiatePing(ctx, &pb.InitiatePingRequest{ + BsEuiHex: "CA-FE-CA-FE-CA-FE-CA-FE", + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.Success) + assert.Equal(t, int64(7), resp.OpId) + assert.Equal(t, highBitEUI, pingCmd.gotEUI, + "ping must target the full-range EUI parsed from hex") + assert.True(t, bytes.Equal(highBitEUIBytes(), capturedEUIBytes), + "ownership check must receive big-endian bytes of the hex EUI") +} + +func TestInitiatePing_RejectsMismatchedEUIs(t *testing.T) { + svc := &CoreService{ + basestationSvc: &mockBasestationSvc{}, + pingCmd: &capturingPingCmd{}, + log: &mockLogger{}, + } + + ctx := testutil.TestContextWithTenant(1) + _, err := svc.InitiatePing(ctx, &pb.InitiatePingRequest{ + BsEui: 0x70B3D59CD00009E6, + BsEuiHex: "CAFECAFECAFECAFE", + }) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + assert.Equal(t, + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBaseStationEUIMismatch), + status.Convert(err).Message()) +} diff --git a/KC-Core/internal/grpc/core_service.go b/KC-Core/internal/grpc/core_service.go index c764dc0..bbdb540 100644 --- a/KC-Core/internal/grpc/core_service.go +++ b/KC-Core/internal/grpc/core_service.go @@ -42,6 +42,12 @@ import ( "github.com/Kiloiot/kilo-service-center/pkg/version" ) +// Endpoint activity status values derived from last-seen recency. +const ( + endpointActivityActive = "active" + endpointActivityInactive = "inactive" +) + // BSSCISessionCloser provides session termination for EUI changes. // Used to close active BSSCI sessions when a base station's EUI is modified. type BSSCISessionCloser interface { @@ -496,6 +502,7 @@ func (s *CoreService) pinnedBlueprintBelongsToModel(ctx context.Context, sourceI return bp.DeviceModelID == modelID } +// CreateEndPoint registers a new endpoint under the authenticated tenant. func (s *CoreService) CreateEndPoint(ctx context.Context, req *pb.CreateEndPointRequest) (*pb.EndPoint, error) { // Extract tenant from authenticated context tenantID, err := GetTenantFromContext(ctx) @@ -646,7 +653,7 @@ func (s *CoreService) CreateEndPoint(ctx context.Context, req *pb.CreateEndPoint // Emit CRUD event if s.eventWriter != nil { - detailsJSON, _ := json.Marshal(map[string]interface{}{"epEui": req.Endpoint.EpEui}) + detailsJSON, _ := json.Marshal(map[string]interface{}{bssci.EventKeyEpEui: req.Endpoint.EpEui}) _ = s.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), EventType: models.EventTypeEndpointCreated, @@ -1065,7 +1072,7 @@ func (s *CoreService) emitEndpointUpdatedEvent(ctx context.Context, tenantID int if s.eventWriter == nil { return } - detailsJSON, _ := json.Marshal(map[string]interface{}{"epEui": epEui}) + detailsJSON, _ := json.Marshal(map[string]interface{}{bssci.EventKeyEpEui: epEui}) _ = s.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), EventType: models.EventTypeEndpointUpdated, @@ -1126,7 +1133,7 @@ func (s *CoreService) DeleteEndPoint(ctx context.Context, req *pb.DeleteEndPoint // Emit CRUD event if s.eventWriter != nil { - detailsJSON, _ := json.Marshal(map[string]interface{}{"epEui": req.EpEui}) + detailsJSON, _ := json.Marshal(map[string]interface{}{bssci.EventKeyEpEui: req.EpEui}) _ = s.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), EventType: models.EventTypeEndpointDeleted, @@ -1394,7 +1401,7 @@ func (s *CoreService) CreateBaseStation(ctx context.Context, req *pb.CreateBaseS // Emit CRUD event if s.eventWriter != nil { - detailsJSON, _ := json.Marshal(map[string]interface{}{"bsEui": req.Basestation.BsEui}) + detailsJSON, _ := json.Marshal(map[string]interface{}{bssci.EventKeyBsEui: req.Basestation.BsEui}) _ = s.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), EventType: models.EventTypeBSRegistered, @@ -1599,7 +1606,7 @@ func (s *CoreService) UpdateBaseStation(ctx context.Context, req *pb.UpdateBaseS // Emit CRUD event if s.eventWriter != nil { - detailsJSON, _ := json.Marshal(map[string]interface{}{"bsEui": req.Basestation.BsEui}) + detailsJSON, _ := json.Marshal(map[string]interface{}{bssci.EventKeyBsEui: req.Basestation.BsEui}) _ = s.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), EventType: models.EventTypeBSUpdated, @@ -1744,7 +1751,7 @@ func (s *CoreService) DeleteBaseStation(ctx context.Context, req *pb.DeleteBaseS // Emit CRUD event if s.eventWriter != nil { - detailsJSON, _ := json.Marshal(map[string]interface{}{"bsEui": req.BsEui}) + detailsJSON, _ := json.Marshal(map[string]interface{}{bssci.EventKeyBsEui: req.BsEui}) _ = s.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), EventType: models.EventTypeBSDeregistered, @@ -2359,10 +2366,10 @@ func (s *CoreService) endpointToProto(endpoint *models.EndPoint) *pb.EndPoint { } // Derive activity status from LastSeenAt and configured window - status := "inactive" + status := endpointActivityInactive if endpoint.LastSeenAt != nil && s.endpointActivityWindow > 0 { if time.Since(*endpoint.LastSeenAt) <= s.endpointActivityWindow { - status = "active" + status = endpointActivityActive } } @@ -2649,13 +2656,18 @@ func (s *CoreService) RequestBaseStationStatus(ctx context.Context, req *pb.Base return nil, err } + bsEui, err := resolveBaseStationEUI(req.BsEuiHex, req.BsEui) //nolint:staticcheck // deprecated field read for wire compatibility with legacy clients + if err != nil { + return nil, err + } + s.log.InfoContext(ctx, "RequestBaseStationStatus request", - "bs_eui", req.BsEui, + "bs_eui", bsEui, "tenant_id", tenantID) bsEuiBytes := make([]byte, 8) for i := 7; i >= 0; i-- { - bsEuiBytes[i] = byte(req.BsEui >> uint(8*(7-i))) // #nosec G115 - i is bounded 0-7, no overflow + bsEuiBytes[i] = byte(bsEui >> uint(8*(7-i))) // #nosec G115 - i is bounded 0-7, no overflow } // Verify tenant ownership first @@ -2671,10 +2683,10 @@ func (s *CoreService) RequestBaseStationStatus(ctx context.Context, req *pb.Base } // Find connected session - sessionIface := s.sessionDir.GetSessionByEUI(req.BsEui) + sessionIface := s.sessionDir.GetSessionByEUI(bsEui) if sessionIface == nil { s.log.Warn("Base station not connected", - "bs_eui", req.BsEui, + "bs_eui", bsEui, "bs_name", bs.Name) return &pb.BaseStationStatusResponse{ Success: false, @@ -2687,7 +2699,7 @@ func (s *CoreService) RequestBaseStationStatus(ctx context.Context, req *pb.Base opId, err := s.statusReq.SendStatusRequest(sessionIface) if err != nil { s.log.ErrorContext(ctx, grpcerrors.LogStatusRequestFailed, - "bs_eui", req.BsEui, + "bs_eui", bsEui, "error", err) return &pb.BaseStationStatusResponse{ Success: false, @@ -2697,7 +2709,7 @@ func (s *CoreService) RequestBaseStationStatus(ctx context.Context, req *pb.Base } s.log.InfoContext(ctx, grpcerrors.MsgStatusRequestSent, - "bs_eui", req.BsEui, + "bs_eui", bsEui, "op_id", opId, "tenant_id", tenantID) @@ -2716,13 +2728,18 @@ func (s *CoreService) InitiatePing(ctx context.Context, req *pb.InitiatePingRequ return nil, err } + bsEui, err := resolveBaseStationEUI(req.BsEuiHex, req.BsEui) //nolint:staticcheck // deprecated field read for wire compatibility with legacy clients + if err != nil { + return nil, err + } + s.log.InfoContext(ctx, "InitiatePing request", - "bs_eui", req.BsEui, + "bs_eui", bsEui, "tenant_id", tenantID) bsEuiBytes := make([]byte, 8) for i := 7; i >= 0; i-- { - bsEuiBytes[i] = byte(req.BsEui >> uint(8*(7-i))) // #nosec G115 - i is bounded 0-7, no overflow + bsEuiBytes[i] = byte(bsEui >> uint(8*(7-i))) // #nosec G115 - i is bounded 0-7, no overflow } // Verify tenant ownership @@ -2738,10 +2755,10 @@ func (s *CoreService) InitiatePing(ctx context.Context, req *pb.InitiatePingRequ } // Call BSSCI server InitiatePing (defense-in-depth tenant validation) - opId, err := s.pingCmd.InitiatePing(ctx, req.BsEui, tenantID) + opId, err := s.pingCmd.InitiatePing(ctx, bsEui, tenantID) if err != nil { s.log.ErrorContext(ctx, grpcerrors.LogPingInitiateFailed, - "bs_eui", req.BsEui, + "bs_eui", bsEui, "error", err) // Branch on CatalogError token for proper gRPC status codes @@ -2761,7 +2778,7 @@ func (s *CoreService) InitiatePing(ctx context.Context, req *pb.InitiatePingRequ } s.log.InfoContext(ctx, grpcerrors.MsgPingRequestSent, - "bs_eui", req.BsEui, + "bs_eui", bsEui, "op_id", opId, "tenant_id", tenantID) @@ -2931,10 +2948,8 @@ func (s *CoreService) GetDLRXStatus(ctx context.Context, req *pb.GetDLRXStatusRe } // Convert to bytes - epEuiBytes := make([]byte, 8) - for i := 0; i < 8; i++ { - epEuiBytes[7-i] = byte(epEui >> (i * 8)) - } + epEuiArr := mioty.EUI64(epEui).ToBytes() + epEuiBytes := epEuiArr[:] // Set pagination defaults limit := int(req.Limit) @@ -3169,10 +3184,8 @@ func (s *CoreService) GetDLRXStatusQueries(ctx context.Context, req *pb.GetDLRXS } // Convert to bytes - epEuiBytes := make([]byte, 8) - for i := 0; i < 8; i++ { - epEuiBytes[7-i] = byte(epEui >> (i * 8)) - } + epEuiArr := mioty.EUI64(epEui).ToBytes() + epEuiBytes := epEuiArr[:] // ===== VALIDATE RAW INPUTS BEFORE DEFAULTING ===== diff --git a/KC-Core/internal/grpc/core_service_blueprints.go b/KC-Core/internal/grpc/core_service_blueprints.go index a932d25..1d0e668 100644 --- a/KC-Core/internal/grpc/core_service_blueprints.go +++ b/KC-Core/internal/grpc/core_service_blueprints.go @@ -50,6 +50,7 @@ func (s *CoreService) requireServerAdmin(ctx context.Context) error { return nil } +// CreateManufacturer creates a device manufacturer catalog entry. func (s *CoreService) CreateManufacturer(ctx context.Context, req *pb.CreateManufacturerRequest) (*pb.CreateManufacturerResponse, error) { if s.blueprintSvc == nil { return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenServiceNotConfigured), diff --git a/KC-Core/internal/grpc/core_service_certs.go b/KC-Core/internal/grpc/core_service_certs.go index 7a9e4fa..a0930ab 100644 --- a/KC-Core/internal/grpc/core_service_certs.go +++ b/KC-Core/internal/grpc/core_service_certs.go @@ -14,6 +14,7 @@ import ( pb "github.com/Kiloiot/kilo-service-center/KC-Core/api/gen/kilocenter/v1" "github.com/Kiloiot/kilo-service-center/KC-Core/internal/services/grpcservices" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" grpcerrors "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" ) @@ -61,8 +62,8 @@ func (s *CoreService) GenerateCertificate(ctx context.Context, req *pb.GenerateC // Emit audit event for certificate generation. if s.eventWriter != nil { detailsMap := map[string]interface{}{ - "bsEui": req.BsEui, - "validityDays": req.ValidityDays, + bssci.EventKeyBsEui: req.BsEui, + "validityDays": req.ValidityDays, } if req.BaseStationName != "" { detailsMap["baseStationName"] = req.BaseStationName diff --git a/KC-Core/internal/grpc/core_service_integrations.go b/KC-Core/internal/grpc/core_service_integrations.go index 3844e04..280d55b 100644 --- a/KC-Core/internal/grpc/core_service_integrations.go +++ b/KC-Core/internal/grpc/core_service_integrations.go @@ -22,6 +22,14 @@ import ( pkgcontext "github.com/Kiloiot/kilo-service-center/pkg/context" ) +// Integration audit event detail keys. +const ( + integrationDetailKeyID = "integrationId" + integrationDetailKeyName = "name" + integrationDetailKeyType = "type" + integrationDetailKeyStatus = "status" +) + // CreateIntegration creates a new integration. func (s *CoreService) CreateIntegration(ctx context.Context, req *pb.CreateIntegrationRequest) (*pb.Integration, error) { if s.integrationSvc == nil { @@ -91,11 +99,11 @@ func (s *CoreService) CreateIntegration(ctx context.Context, req *pb.CreateInteg if s.eventWriter != nil { sourceID := orgID detailsJSON, _ := json.Marshal(map[string]interface{}{ - "integrationId": integration.ID, - "name": req.Name, - "type": req.Type, - "status": integration.Status, - "orgId": orgID.String(), + integrationDetailKeyID: integration.ID, + integrationDetailKeyName: req.Name, + integrationDetailKeyType: req.Type, + integrationDetailKeyStatus: integration.Status, + "orgId": orgID.String(), }) _ = s.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), @@ -211,10 +219,10 @@ func (s *CoreService) UpdateIntegration(ctx context.Context, req *pb.UpdateInteg // Emit audit event for integration update. if s.eventWriter != nil { detailsMap := map[string]interface{}{ - "integrationId": integration.ID, - "name": integration.Name, - "type": integration.Type, - "status": integration.Status, + integrationDetailKeyID: integration.ID, + integrationDetailKeyName: integration.Name, + integrationDetailKeyType: integration.Type, + integrationDetailKeyStatus: integration.Status, } evt := &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), @@ -281,9 +289,9 @@ func (s *CoreService) DeleteIntegration(ctx context.Context, req *pb.DeleteInteg sourceName = strconv.FormatInt(req.Id, 10) } detailsMap := map[string]interface{}{ - "integrationId": req.Id, - "type": integrationType, - "status": integrationStatus, + integrationDetailKeyID: req.Id, + integrationDetailKeyType: integrationType, + integrationDetailKeyStatus: integrationStatus, } evt := &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), diff --git a/KC-Core/internal/health/health.go b/KC-Core/internal/health/health.go index 4097351..68fc736 100644 --- a/KC-Core/internal/health/health.go +++ b/KC-Core/internal/health/health.go @@ -266,7 +266,7 @@ func (c *HTTPChecker) Check(ctx context.Context) *Check { } client := &http.Client{Timeout: c.timeout} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) //nolint:gosec // G704: URL comes from operator configuration, not request input if err != nil { check.Status = StatusUnhealthy check.Message = fmt.Sprintf("%s: %v", MsgFailedToCreate, err) @@ -274,7 +274,7 @@ func (c *HTTPChecker) Check(ctx context.Context) *Check { return check } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:gosec // G704: URL comes from operator configuration, not request input if err != nil { check.Status = StatusUnhealthy check.Message = fmt.Sprintf("%s: %v", MsgFailedToConnect, err) diff --git a/KC-Core/internal/services/blueprints/service.go b/KC-Core/internal/services/blueprints/service.go index ff4e96e..efb5414 100644 --- a/KC-Core/internal/services/blueprints/service.go +++ b/KC-Core/internal/services/blueprints/service.go @@ -24,6 +24,9 @@ import ( "github.com/google/uuid" ) +// fallbackModelSlug is used when slug generation strips every character. +const fallbackModelSlug = "model" + // Sentinel errors for blueprint operations. var ( ErrManufacturerNotFound = errors.New("manufacturer not found") @@ -150,7 +153,7 @@ func generateSlug(name string) string { slug = slug[:blueprintconstants.ModelCodeMaxLength] } if slug == "" { - slug = "model" + slug = fallbackModelSlug } return slug } @@ -815,9 +818,9 @@ func sanitizePathSegment(s string) (string, error) { return "", ErrInvalidRegistryPathSegment } slug := generateSlug(s) - // generateSlug returns "model" as fallback when all characters are stripped. + // generateSlug returns fallbackModelSlug as fallback when all characters are stripped. // Reject if the slug is the fallback and the input had no alphanumeric content. - if slug == "model" { + if slug == fallbackModelSlug { cleaned := slugRegex.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), "") cleaned = strings.Trim(cleaned, "-") if cleaned == "" { diff --git a/KC-Core/internal/services/bssci/connection_service.go b/KC-Core/internal/services/bssci/connection_service.go index 2760665..26419c0 100644 --- a/KC-Core/internal/services/bssci/connection_service.go +++ b/KC-Core/internal/services/bssci/connection_service.go @@ -7,6 +7,7 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" ) type connectionService struct { @@ -59,10 +60,7 @@ func (c *connectionService) RegisterConnection(ctx context.Context, session *bss } // Convert EUI to [8]byte for UpdateConnectionStatus - var euiBytes [8]byte - for i := 7; i >= 0; i-- { - euiBytes[i] = byte(session.BaseStationEUI >> (8 * (7 - i))) - } + euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() err := mgr.UpdateConnectionStatus(ctx, euiBytes, status) if err != nil { diff --git a/KC-Core/internal/services/bssci/endpoint_attachment_service.go b/KC-Core/internal/services/bssci/endpoint_attachment_service.go index 2a9b8c1..74a2b0d 100644 --- a/KC-Core/internal/services/bssci/endpoint_attachment_service.go +++ b/KC-Core/internal/services/bssci/endpoint_attachment_service.go @@ -15,6 +15,7 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/common/validation" "github.com/Kiloiot/kilo-service-center/KC-DB/storage" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" ) @@ -65,10 +66,7 @@ func (s *endpointAttachmentService) AttachEndPoint(ctx context.Context, epEui st } // Convert uint64 to models.EUI (8 bytes, big-endian) - var eui models.EUI - for i := 0; i < 8; i++ { - eui[7-i] = byte(euiU64 >> (i * 8)) - } + eui := models.EUI(mioty.EUI64(euiU64).ToBytes()) // Get endpoint using repository endpoint, err := s.endpointRepo.GetByEUI(ctx, tenantID, eui[:]) @@ -134,10 +132,7 @@ func (s *endpointAttachmentService) DetachEndPoint(ctx context.Context, epEui st } // Convert uint64 to models.EUI (8 bytes, big-endian) - var eui models.EUI - for i := 0; i < 8; i++ { - eui[7-i] = byte(euiU64 >> (i * 8)) - } + eui := models.EUI(mioty.EUI64(euiU64).ToBytes()) // Get endpoint using repository endpoint, err := s.endpointRepo.GetByEUI(ctx, tenantID, eui[:]) diff --git a/KC-Core/internal/services/bssci/factories.go b/KC-Core/internal/services/bssci/factories.go index 4cb9951..57af1c1 100644 --- a/KC-Core/internal/services/bssci/factories.go +++ b/KC-Core/internal/services/bssci/factories.go @@ -14,6 +14,7 @@ import ( // BSSCIServiceBundle packages all BSSCI service dependencies type BSSCIServiceBundle struct { SessionSvc bssci.SessionService + VersionNegotiator bssci.VersionNegotiator DownlinkSvc bssci.DownlinkService StatusSvc bssci.StatusService ConnectionSvc bssci.ConnectionService @@ -49,8 +50,14 @@ func NewBSSCIServices( orgResolver org.Resolver, pendingOps *map[bssci.SessionOpKey]*bssci.PendingOperation, pendingOpsMu *sync.RWMutex, -) *BSSCIServiceBundle { + supportedProtocolVersions []string, +) (*BSSCIServiceBundle, error) { // Create services in dependency order + versionNegotiator, err := NewVersionNegotiator(supportedProtocolVersions, log) + if err != nil { + return nil, err + } + // SessionService uses repository interfaces sessionSvc := NewSessionService( storage.BaseStationSessions(), @@ -88,6 +95,7 @@ func NewBSSCIServices( return &BSSCIServiceBundle{ SessionSvc: sessionSvc, + VersionNegotiator: versionNegotiator, DownlinkSvc: downlinkSvc, StatusSvc: statusSvc, ConnectionSvc: connectionSvc, @@ -96,5 +104,5 @@ func NewBSSCIServices( QueueSerializer: queueSerializer, AuditLogger: auditLogger, TenantResolver: tenantResolver, - } + }, nil } diff --git a/KC-Core/internal/services/bssci/mqtt_adapter.go b/KC-Core/internal/services/bssci/mqtt_adapter.go index 1b38a62..46bdc49 100644 --- a/KC-Core/internal/services/bssci/mqtt_adapter.go +++ b/KC-Core/internal/services/bssci/mqtt_adapter.go @@ -9,6 +9,13 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-MQTT/pkg/mqtt" ) +// MQTT payload field keys shared by the published event bodies. +const ( + mqttKeyEpEui = "epEui" + mqttKeyBsEui = "bsEui" + mqttKeyQueId = "queId" +) + // mqttAdapter bridges bssci.MQTTEventPublisher → mqtt.DeviceEventPublisher.PublishDeviceEvent. // Converts uint64 EUIs to hex strings, builds JSON payloads, and delegates to KC-MQTT. type mqttAdapter struct { @@ -24,12 +31,12 @@ func (a *mqttAdapter) PublishUplink(ctx context.Context, orgUUID string, epEUI u rssi float64, snr float64, rxTime int64, packetCnt uint32, userData []byte, decodedPayload []byte) error { epEUIHex := fmt.Sprintf("%016x", epEUI) msg := map[string]interface{}{ - "bsEui": fmt.Sprintf("%016x", bsEUI), - "rssi": rssi, - "snr": snr, - "rxTime": rxTime, - "cnt": packetCnt, - "data": userData, + mqttKeyBsEui: fmt.Sprintf("%016x", bsEUI), + "rssi": rssi, + "snr": snr, + "rxTime": rxTime, + "cnt": packetCnt, + "data": userData, } if len(decodedPayload) > 0 { msg["decodedPayload"] = json.RawMessage(decodedPayload) @@ -44,9 +51,9 @@ func (a *mqttAdapter) PublishUplink(ctx context.Context, orgUUID string, epEUI u func (a *mqttAdapter) PublishAttach(ctx context.Context, orgUUID string, epEUI uint64, bsEUI uint64) error { epEUIHex := fmt.Sprintf("%016x", epEUI) payload, err := json.Marshal(map[string]interface{}{ - "epEui": epEUIHex, - "bsEui": fmt.Sprintf("%016x", bsEUI), - "event": "attach", + mqttKeyEpEui: epEUIHex, + mqttKeyBsEui: fmt.Sprintf("%016x", bsEUI), + "event": "attach", }) if err != nil { return fmt.Errorf("mqtt adapter: failed to marshal attach payload: %w", err) @@ -57,9 +64,9 @@ func (a *mqttAdapter) PublishAttach(ctx context.Context, orgUUID string, epEUI u func (a *mqttAdapter) PublishDetach(ctx context.Context, orgUUID string, epEUI uint64, bsEUI uint64) error { epEUIHex := fmt.Sprintf("%016x", epEUI) payload, err := json.Marshal(map[string]interface{}{ - "epEui": epEUIHex, - "bsEui": fmt.Sprintf("%016x", bsEUI), - "event": "detach", + mqttKeyEpEui: epEUIHex, + mqttKeyBsEui: fmt.Sprintf("%016x", bsEUI), + "event": "detach", }) if err != nil { return fmt.Errorf("mqtt adapter: failed to marshal detach payload: %w", err) @@ -70,9 +77,9 @@ func (a *mqttAdapter) PublishDetach(ctx context.Context, orgUUID string, epEUI u func (a *mqttAdapter) PublishDownlinkResult(ctx context.Context, orgUUID string, epEUI uint64, queID uint64, result string) error { epEUIHex := fmt.Sprintf("%016x", epEUI) payload, err := json.Marshal(map[string]interface{}{ - "epEui": epEUIHex, - "queId": queID, - "result": result, + mqttKeyEpEui: epEUIHex, + mqttKeyQueId: queID, + "result": result, }) if err != nil { return fmt.Errorf("mqtt adapter: failed to marshal downlink result payload: %w", err) diff --git a/KC-Core/internal/services/bssci/queue_serializer.go b/KC-Core/internal/services/bssci/queue_serializer.go index 475ed75..55626b5 100644 --- a/KC-Core/internal/services/bssci/queue_serializer.go +++ b/KC-Core/internal/services/bssci/queue_serializer.go @@ -5,6 +5,12 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" ) +// BSSCI wire envelope keys for serialized response frames. +const ( + wireKeyCommand = "command" + wireKeyOpId = "opId" //nolint:revive // BSSCI §2.5 requires lowercase 'd' +) + // queueSerializer implements bssci.QueueSerializer interface // // This service builds canonical BSSCI downlink response frames per MIOTY spec. @@ -34,8 +40,8 @@ func NewQueueSerializer() bssci.QueueSerializer { // Service Center confirms completion with this message. func (q *queueSerializer) BuildDLDataQueueComplete(opId int64) map[string]interface{} { return map[string]interface{}{ - "command": mioty.CmdDLDataQueueComplete, - "opId": opId, + wireKeyCommand: mioty.CmdDLDataQueueComplete, + wireKeyOpId: opId, } } @@ -51,8 +57,8 @@ func (q *queueSerializer) BuildDLDataResultResponse(opId int64, result *mioty.DL _ = result // Unused per BSSCI §5.14.2 (queId/success not included in response) // Spec §5.14.2: dlDataResRsp MUST contain only command/opId. return map[string]interface{}{ - "command": mioty.CmdDLDataResultResponse, - "opId": opId, + wireKeyCommand: mioty.CmdDLDataResultResponse, + wireKeyOpId: opId, } } @@ -67,8 +73,8 @@ func (q *queueSerializer) BuildDLDataResultResponse(opId int64, result *mioty.DL // acknowledged (dlDataResRsp), and this message confirms completion. func (q *queueSerializer) BuildDLDataResultComplete(opId int64) map[string]interface{} { return map[string]interface{}{ - "command": mioty.CmdDLDataResultComplete, - "opId": opId, + wireKeyCommand: mioty.CmdDLDataResultComplete, + wireKeyOpId: opId, } } @@ -83,7 +89,7 @@ func (q *queueSerializer) BuildDLDataResultComplete(opId int64) map[string]inter // Service Center confirms completion with this message. func (q *queueSerializer) BuildDLDataRevokeComplete(opID int64) map[string]interface{} { return map[string]interface{}{ - "command": mioty.CmdDLDataRevokeComplete, - "opId": opID, + wireKeyCommand: mioty.CmdDLDataRevokeComplete, + wireKeyOpId: opID, } } diff --git a/KC-Core/internal/services/bssci/session_resume_db_test.go b/KC-Core/internal/services/bssci/session_resume_db_test.go index 2bb64bb..4143002 100644 --- a/KC-Core/internal/services/bssci/session_resume_db_test.go +++ b/KC-Core/internal/services/bssci/session_resume_db_test.go @@ -15,51 +15,57 @@ import ( "github.com/stretchr/testify/require" ) -// TestHandleResume_DBLookup_ByScUuid tests database fallback with SC UUID lookup -func TestHandleResume_DBLookup_ByScUuid(t *testing.T) { - // Setup: Create session service with mock repository +func newResumeTestService(t *testing.T, tenantID int64) (*sessionService, *mockBaseStationSessionRepo) { + t.Helper() mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() - svc := NewSessionService( mockRepo, - mockBsRepo, - mockEventStore, - 100, // tenant ID - testLogger, + &mockBaseStationRepo{}, + &mockSystemEventStore{}, + tenantID, + logger.NewNop(), ).(*sessionService) + return svc, mockRepo +} - // Setup: Create a persisted session in DB - scUUID := [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10} - bsUUID := [16]byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20} - orgID := uuid.New() - - protocolVersion := "1.0.0" - dbSession := &models.BaseStationSession{ - ID: 42, +func seedResumableSession(repo *mockBaseStationSessionRepo, id, tenantID int64, bsUUID, scUUID [16]byte, bsOpId, scOpId int64) *models.BaseStationSession { + protocolVersion := mioty.MIOTYProtocolVersion + session := &models.BaseStationSession{ + ID: id, BaseStationID: 1, - TenantID: 100, + TenantID: tenantID, SnBsUuid: bsUUID, SnScUuid: scUUID, - SnBsOpId: 1000, - SnScOpId: -500, - Status: models.SessionStatusActive, + SnBsOpId: bsOpId, + SnScOpId: scOpId, + Status: models.SessionStatusDisconnected, CanResume: true, Encoding: "msgpack", - ProtocolVersion: &protocolVersion, // BSSCI §4-4.5: protocol version persisted - OrganizationID: &orgID, + ProtocolVersion: &protocolVersion, StartedAt: time.Now().Add(-1 * time.Hour), } - mockRepo.sessions[42] = dbSession + repo.sessions[id] = session + return session +} + +func int64Ptr(v int64) *int64 { return &v } + +// TestHandleResume_DBLookup_BySnBsUuid verifies the database fallback: resume +// identity is snBsUuid scoped by tenant and base station EUI, matching only +// disconnected, resumable sessions (BSSCI §5.3.1). +func TestHandleResume_DBLookup_BySnBsUuid(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) + + scUUID := [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10} + bsUUID := [16]byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20} + orgID := uuid.New() + dbSession := seedResumableSession(mockRepo, 42, 100, bsUUID, scUUID, 1000, -500) + dbSession.OrganizationID = &orgID - // Execute: Call HandleResume with SC UUID - // Create session with matching tenant ID for DB lookup testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(testSession, bsUUID[:], scUUID[:], 1001, -500, 0x123456789ABCDEF0) + restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + int64Ptr(1000), int64Ptr(-500), 0x123456789ABCDEF0) - // Verify: Session restored with all fields require.NoError(t, err) require.NotNil(t, restoredSession) assert.Equal(t, int64(42), restoredSession.DbSessionID) @@ -68,265 +74,180 @@ func TestHandleResume_DBLookup_ByScUuid(t *testing.T) { assert.Equal(t, int64(1000), restoredSession.LastBsOpId) assert.Equal(t, int64(-500), restoredSession.LastScOpId) assert.Equal(t, "msgpack", restoredSession.Encoding) - assert.Equal(t, "1.0.0", restoredSession.NegotiatedVersion) // BSSCI §4-4.5: version restored - assert.NotEmpty(t, restoredSession.ClientVersion) // Client version populated from DB + assert.Equal(t, mioty.MIOTYProtocolVersion, restoredSession.NegotiatedVersion) assert.Equal(t, orgID, restoredSession.OrganizationID) assert.Equal(t, int64(100), restoredSession.ResolvedTenantID) assert.Equal(t, uint64(0x123456789ABCDEF0), restoredSession.BaseStationEUI) - assert.True(t, restoredSession.IsResumed) - - // Verify: Session re-populated in in-memory map - cachedSession, exists := svc.sessionsByUUID[string(scUUID[:])] - assert.True(t, exists) - assert.Equal(t, restoredSession, cachedSession) } -// TestHandleResume_DBLookup_ByBsUuid tests fallback with BS UUID lookup -func TestHandleResume_DBLookup_ByBsUuid(t *testing.T) { - // Setup: Create session service with mock repository - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() +// TestHandleResume_AbsentCounters verifies that absent snBsOpId/snScOpId +// constraints are not asserted (BSSCI §5.3.1 optional fields). +func TestHandleResume_AbsentCounters(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 200, // tenant ID - testLogger, - ).(*sessionService) - - // Setup: Create session with only BS UUID match (SC UUID mismatch) bsUUID := [16]byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30} - scUUID := [16]byte{0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40} - - dbSession := &models.BaseStationSession{ - ID: 43, - BaseStationID: 2, - TenantID: 200, - SnBsUuid: bsUUID, - SnScUuid: scUUID, - SnBsOpId: 2000, - SnScOpId: -1000, - Status: models.SessionStatusActive, - CanResume: true, - Encoding: "json", - OrganizationID: nil, // Test nil pointer handling - StartedAt: time.Now().Add(-30 * time.Minute), - } - mockRepo.sessions[43] = dbSession + seedResumableSession(mockRepo, 43, 100, bsUUID, [16]byte{0x31}, 2000, -900) - // Execute: Call HandleResume with mismatched SC UUID (forces BS UUID fallback) - wrongScUUID := [16]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} - testSession := &bssci.Session{ResolvedTenantID: 200} - restoredSession, err := svc.HandleResume(testSession, bsUUID[:], wrongScUUID[:], 2001, -1000, 0xFEDCBA9876543210) + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0xAABB) - // Verify: BS UUID fallback succeeds require.NoError(t, err) require.NotNil(t, restoredSession) - assert.Equal(t, int64(43), restoredSession.DbSessionID) - assert.Equal(t, int64(2000), restoredSession.LastBsOpId) - assert.Equal(t, int64(-1000), restoredSession.LastScOpId) - assert.Equal(t, "json", restoredSession.Encoding) - assert.Equal(t, uuid.Nil, restoredSession.OrganizationID) // Nil pointer converted to uuid.Nil - assert.Equal(t, int64(200), restoredSession.ResolvedTenantID) - assert.True(t, restoredSession.IsResumed) + assert.Equal(t, int64(2000), restoredSession.LastBsOpId, + "authoritative persisted counters restored when constraints absent") + assert.Equal(t, int64(-900), restoredSession.LastScOpId) } -// TestHandleResume_ShortUUID_SkipsDBLookup tests that short UUIDs don't trigger DB queries -func TestHandleResume_ShortUUID_SkipsDBLookup(t *testing.T) { - // Setup: Create session service - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() +// TestHandleResume_StaleScCounterAccepted verifies a stale (less negative) +// snScOpId is compatible: the service center is authoritative for its own +// operation IDs. +func TestHandleResume_StaleScCounterAccepted(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 300, - testLogger, - ).(*sessionService) + bsUUID := [16]byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50} + seedResumableSession(mockRepo, 44, 100, bsUUID, [16]byte{0x51}, 500, -800) - // Execute: Call HandleResume with short UUIDs (< 16 bytes) - shortUUID := []byte{0x01, 0x02, 0x03} // Only 3 bytes - testSession := &bssci.Session{ResolvedTenantID: 300} - restoredSession, err := svc.HandleResume(testSession, shortUUID, shortUUID, 100, -50, 0x123456789ABCDEF0) + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + int64Ptr(400), int64Ptr(-700), 0xCCDD) - // Verify: Returns nil (no panic, no DB query) require.NoError(t, err) + require.NotNil(t, restoredSession) + assert.Equal(t, int64(-800), restoredSession.LastScOpId, + "the authoritative persisted SC counter is restored, not the stale reported one") +} + +// TestHandleResume_RequiredBsOpIdBeyondPersisted verifies rejection when the +// base station requires a minimum operation state the service center does not +// know (snBsOpId above the persisted counter). +func TestHandleResume_RequiredBsOpIdBeyondPersisted(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) + + bsUUID := [16]byte{0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70} + seedResumableSession(mockRepo, 45, 100, bsUUID, [16]byte{0x71}, 1000, -500) + + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + int64Ptr(1001), int64Ptr(-500), 0xEEFF) + + require.ErrorIs(t, err, bssci.ErrResumeCounterMismatch) assert.Nil(t, restoredSession) } -// TestHandleResume_NilUUID_SkipsDBLookup tests that nil UUIDs are handled gracefully -func TestHandleResume_NilUUID_SkipsDBLookup(t *testing.T) { - // Setup - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() +// TestHandleResume_ClaimedScOpIdBeyondIssued verifies rejection when the base +// station claims a more negative SC operation ID than the service center ever +// issued. +func TestHandleResume_ClaimedScOpIdBeyondIssued(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 400, - testLogger, - ).(*sessionService) + bsUUID := [16]byte{0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90} + seedResumableSession(mockRepo, 46, 100, bsUUID, [16]byte{0x91}, 1000, -500) - // Execute: Call HandleResume with nil UUIDs - testSession := &bssci.Session{ResolvedTenantID: 400} - restoredSession, err := svc.HandleResume(testSession, nil, nil, 100, -50, 0x123456789ABCDEF0) + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + int64Ptr(1000), int64Ptr(-501), 0xEEFF) - // Verify: Returns nil cleanly (no panic) - require.NoError(t, err) + require.ErrorIs(t, err, bssci.ErrResumeCounterMismatch) assert.Nil(t, restoredSession) } -// TestHandleResume_NoDBMatch_ReturnNil tests fallback to fresh session -func TestHandleResume_NoDBMatch_ReturnNil(t *testing.T) { - // Setup: Empty repository - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() +// TestHandleResume_TerminatedNotResumable verifies terminated sessions never +// resume regardless of matching identity. +func TestHandleResume_TerminatedNotResumable(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 500, - testLogger, - ).(*sessionService) + bsUUID := [16]byte{0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0} + session := seedResumableSession(mockRepo, 47, 100, bsUUID, [16]byte{0xB1}, 100, -50) + session.Status = models.SessionStatusTerminated + session.CanResume = false - // Execute: Try to resume non-existent session - unknownUUID := [16]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99} - testSession := &bssci.Session{ResolvedTenantID: 500} - restoredSession, err := svc.HandleResume(testSession, unknownUUID[:], unknownUUID[:], 100, -50, 0x123456789ABCDEF0) + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) - // Verify: Returns nil for fresh session fallback require.NoError(t, err) - assert.Nil(t, restoredSession) + assert.Nil(t, restoredSession, "terminated sessions must not resume") } -// TestHandleResume_UsesServerTenantID tests tenant isolation -func TestHandleResume_UsesServerTenantID(t *testing.T) { - // Setup: Repository with session for tenant 600 - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() +// TestHandleResume_ActiveNotResumableFromDB verifies only disconnected +// sessions qualify for the database resume path. +func TestHandleResume_ActiveNotResumableFromDB(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) - // Create service with tenant 999 (different from session's tenant) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 999, // Service tenant ID - testLogger, - ).(*sessionService) + bsUUID := [16]byte{0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0} + session := seedResumableSession(mockRepo, 48, 100, bsUUID, [16]byte{0xD1}, 100, -50) + session.Status = models.SessionStatusActive - scUUID := [16]byte{0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F} - bsUUID := [16]byte{0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F} - - dbSession := &models.BaseStationSession{ - ID: 44, - BaseStationID: 3, - TenantID: 600, // Different from service tenant - SnBsUuid: bsUUID, - SnScUuid: scUUID, - SnBsOpId: 3000, - SnScOpId: -1500, - Status: models.SessionStatusActive, - CanResume: true, - Encoding: "msgpack", - OrganizationID: nil, - StartedAt: time.Now().Add(-2 * time.Hour), - } - mockRepo.sessions[44] = dbSession + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) - // Execute: Resume session - use tenant 600 to match DB session, not service's 999 - testSession := &bssci.Session{ResolvedTenantID: 600} - restoredSession, err := svc.HandleResume(testSession, bsUUID[:], scUUID[:], 3001, -1500, 0xABCDEF0123456789) + require.NoError(t, err) + assert.Nil(t, restoredSession, "only disconnected sessions are resumable from persistence") +} + +// TestHandleResume_ShortUUID_SkipsDBLookup verifies malformed UUIDs never +// reach the database. +func TestHandleResume_ShortUUID_SkipsDBLookup(t *testing.T) { + svc, _ := newResumeTestService(t, 100) + + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, []byte{0x01, 0x02}, + int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) - // Verify: Session restored with DB's tenant ID (not service's default) require.NoError(t, err) - require.NotNil(t, restoredSession) - assert.Equal(t, int64(600), restoredSession.ResolvedTenantID) // From DB, not service.tenantID + assert.Nil(t, restoredSession) } -// TestHandleResume_TenantIsolation verifies sessions are isolated by tenant ID -func TestHandleResume_TenantIsolation(t *testing.T) { - // Setup: Create session service for tenant 800 - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() +// TestHandleResume_NilUUID_SkipsDBLookup verifies nil UUIDs never reach the +// database. +func TestHandleResume_NilUUID_SkipsDBLookup(t *testing.T) { + svc, _ := newResumeTestService(t, 100) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 800, // Service default tenant - testLogger, - ).(*sessionService) + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, nil, + int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) - // Setup: Create session in DB for tenant 900 (different from service) - scUUID := [16]byte{0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F} - bsUUID := [16]byte{0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F} - - dbSession := &models.BaseStationSession{ - ID: 45, - BaseStationID: 5, - TenantID: 900, // Tenant 900 owns this session - SnBsUuid: bsUUID, - SnScUuid: scUUID, - SnBsOpId: 5000, - SnScOpId: -2500, - Status: models.SessionStatusActive, - CanResume: true, - Encoding: "msgpack", - OrganizationID: nil, - StartedAt: time.Now().Add(-1 * time.Hour), - } - mockRepo.sessions[45] = dbSession + require.NoError(t, err) + assert.Nil(t, restoredSession) +} + +// TestHandleResume_NoDBMatch_ReturnNil verifies unknown UUIDs fall back to a +// fresh session. +func TestHandleResume_NoDBMatch_ReturnNil(t *testing.T) { + svc, _ := newResumeTestService(t, 100) - // Execute: Attempt to resume with wrong tenant (800 instead of 900) - wrongTenantSession := &bssci.Session{ResolvedTenantID: 800} - restoredSession, err := svc.HandleResume(wrongTenantSession, bsUUID[:], scUUID[:], 5001, -2500, 0x1122334455667788) + unknownUUID := [16]byte{0xDE, 0xAD, 0xBE, 0xEF} + testSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), testSession, unknownUUID[:], + int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) - // Verify: Tenant isolation - should NOT find session from different tenant require.NoError(t, err) - assert.Nil(t, restoredSession, "Should not resume session from different tenant") + assert.Nil(t, restoredSession) +} + +// TestHandleResume_TenantIsolation verifies a session persisted under another +// tenant never resumes, even with a matching UUID. +func TestHandleResume_TenantIsolation(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) - // Execute: Attempt to resume with correct tenant (900) - correctTenantSession := &bssci.Session{ResolvedTenantID: 900} - restoredSession, err = svc.HandleResume(correctTenantSession, bsUUID[:], scUUID[:], 5001, -2500, 0x1122334455667788) + bsUUID := [16]byte{0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0} + seedResumableSession(mockRepo, 49, 500, bsUUID, [16]byte{0xF1}, 5000, -2500) - // Verify: Correct tenant can resume the session + wrongTenantSession := &bssci.Session{ResolvedTenantID: 100} + restoredSession, err := svc.HandleResume(context.Background(), wrongTenantSession, bsUUID[:], + int64Ptr(5000), int64Ptr(-2500), 0x1122334455667788) require.NoError(t, err) - require.NotNil(t, restoredSession, "Should resume session with correct tenant") - assert.Equal(t, int64(45), restoredSession.DbSessionID) - assert.Equal(t, int64(900), restoredSession.ResolvedTenantID) + assert.Nil(t, restoredSession, "cross-tenant UUID collisions must not resume") + + correctTenantSession := &bssci.Session{ResolvedTenantID: 500} + restoredSession, err = svc.HandleResume(context.Background(), correctTenantSession, bsUUID[:], + int64Ptr(5000), int64Ptr(-2500), 0x1122334455667788) + require.NoError(t, err) + require.NotNil(t, restoredSession) + assert.Equal(t, int64(500), restoredSession.ResolvedTenantID) } -// TestHydrateSessionFromDB_OrganizationIDNilSafety tests nil pointer conversion func TestHydrateSessionFromDB_OrganizationIDNilSafety(t *testing.T) { - // Setup - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() - - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 700, - testLogger, - ).(*sessionService) + svc, _ := newResumeTestService(t, 700) // Test cases: nil vs. non-nil OrganizationID testCases := []struct { @@ -360,7 +281,7 @@ func TestHydrateSessionFromDB_OrganizationIDNilSafety(t *testing.T) { SnScUuid: [16]byte{}, SnBsOpId: 4000, SnScOpId: -2000, - Status: models.SessionStatusActive, + Status: models.SessionStatusDisconnected, CanResume: true, Encoding: "json", OrganizationID: tc.orgID, @@ -378,18 +299,7 @@ func TestHydrateSessionFromDB_OrganizationIDNilSafety(t *testing.T) { // TestPersistSessionResumeUpdatesProtocolVersion ensures resume path persists negotiated version to repository func TestPersistSessionResumeUpdatesProtocolVersion(t *testing.T) { - mockRepo := newMockBaseStationSessionRepo() - mockBsRepo := &mockBaseStationRepo{} - mockEventStore := &mockSystemEventStore{} - testLogger := logger.NewNop() - - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 800, - testLogger, - ).(*sessionService) + svc, mockRepo := newResumeTestService(t, 800) // Seed repository with existing session lacking protocol_version (legacy row) scUUID := [16]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A} @@ -402,12 +312,13 @@ func TestPersistSessionResumeUpdatesProtocolVersion(t *testing.T) { SnScUuid: scUUID, SnBsOpId: 3000, SnScOpId: -1500, - Status: models.SessionStatusActive, + Status: models.SessionStatusDisconnected, CanResume: true, Encoding: "msgpack", } session := &bssci.Session{ + ID: "resume-connection-1", ResolvedTenantID: 800, SessionUUID: append([]byte(nil), scUUID[:]...), BsUUID: append([]byte(nil), bsUUID[:]...), @@ -418,10 +329,60 @@ func TestPersistSessionResumeUpdatesProtocolVersion(t *testing.T) { err := svc.PersistSession(context.Background(), session, nil, true, nil) require.NoError(t, err) - // Verify repository row now has protocol_version populated + // Verify repository row now has protocol_version populated and the resume + // activation restored the active, resumable state stored := mockRepo.sessions[99] require.NotNil(t, stored) if assert.NotNil(t, stored.ProtocolVersion, "protocol_version should be persisted on resume") { assert.Equal(t, mioty.MIOTYProtocolVersion, *stored.ProtocolVersion) } + assert.Equal(t, models.SessionStatusActive, stored.Status, "resume activation sets active status") + assert.True(t, stored.CanResume, "resume activation keeps the session resumable") + assert.Nil(t, stored.EndedAt, "resume activation clears ended_at") + if assert.NotNil(t, stored.ConnectionId) { + assert.Equal(t, "resume-connection-1", *stored.ConnectionId) + } +} + +// TestMarkDisconnected_StaleConnectionDoesNotTouchNewerSession verifies the +// stale-cleanup guard: after a reconnect activates connection B, the deferred +// cleanup of replaced connection A matches zero rows and B stays active. +func TestMarkDisconnected_StaleConnectionDoesNotTouchNewerSession(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 900) + + scUUID := [16]byte{0x10, 0x20, 0x30} + bsUUID := [16]byte{0x40, 0x50, 0x60} + dbSession := seedResumableSession(mockRepo, 77, 900, bsUUID, scUUID, 10, -5) + + // Connection B resumed and activated the session + sessionB := &bssci.Session{ + ID: "connection-b", + ResolvedTenantID: 900, + DbSessionID: 77, + SessionUUID: append([]byte(nil), scUUID[:]...), + BsUUID: append([]byte(nil), bsUUID[:]...), + NegotiatedVersion: mioty.MIOTYProtocolVersion, + Encoding: "msgpack", + } + require.NoError(t, svc.PersistSession(context.Background(), sessionB, nil, true, nil)) + require.Equal(t, models.SessionStatusActive, dbSession.Status) + + // Connection A's deferred cleanup runs after B took over + sessionA := &bssci.Session{ + ID: "connection-a", + ResolvedTenantID: 900, + DbSessionID: 77, + } + require.NoError(t, svc.MarkDisconnected(context.Background(), sessionA)) + + // B remains active and resumable state is untouched by A's cleanup + assert.Equal(t, models.SessionStatusActive, dbSession.Status, + "stale cleanup must not disconnect the newer connection's session") + assert.Nil(t, dbSession.EndedAt) + + // B's own later cleanup does transition the session + require.NoError(t, svc.MarkDisconnected(context.Background(), sessionB)) + assert.Equal(t, models.SessionStatusDisconnected, dbSession.Status) + assert.True(t, dbSession.CanResume) + assert.NotNil(t, dbSession.EndedAt) } diff --git a/KC-Core/internal/services/bssci/session_service.go b/KC-Core/internal/services/bssci/session_service.go index 8388f3c..d059b75 100644 --- a/KC-Core/internal/services/bssci/session_service.go +++ b/KC-Core/internal/services/bssci/session_service.go @@ -55,267 +55,89 @@ func (s *sessionService) resolvedTenantID(session *bssci.Session) int64 { return s.tenantID // CRITICAL: Use s.tenantID, not s.defaultTenantID } -// ValidateVersion checks protocol version compatibility (BSSCI 2.1-2.2) -// Returns specific CatalogError tokens per BSSCI 2.1-2.2. -func (s *sessionService) ValidateVersion(version string) error { - // Parse base station version - bsMajor, bsMinor, _, cerr := bssci.ParseVersion(version) - if cerr != nil { - // Pass through specific error token from parseVersion - // (errInvalidVersionFormat, errInvalidMajorVersion, errInvalidMinorVersion, errInvalidPatchVersion) - return cerr +// HandleResume validates a session resume request per BSSCI §5.3.1. Resume +// identity is snBsUuid scoped by tenant and base station EUI. The optional +// snBsOpId (minimum required known BS operation ID) and snScOpId (maximum +// known SC operation ID) constraints are asserted only when present: +// a required BS operation ID above what is persisted means the service +// center cannot satisfy the base station's minimum state; a claimed SC +// operation ID below what was issued is impossible without tampering. +// After acceptance the authoritative persisted counters are restored, not +// the constraint values reported in the connect message. +func (s *sessionService) HandleResume(ctx context.Context, session *bssci.Session, bsUUID []byte, bsOpId, scOpId *int64, bsEUI uint64) (*bssci.Session, error) { + if len(bsUUID) != 16 { + return nil, nil } - // Parse server version - scMajor, scMinor, _, cerr := bssci.ParseVersion(mioty.MIOTYProtocolVersion) - if cerr != nil { - // This should never happen unless MIOTYProtocolVersion is misconfigured - s.logger.Error(bssci.LogBSSCIInvalidServerProtocolVersion, "version", mioty.MIOTYProtocolVersion) - return bssci.NewCatalogError(bssci.ErrVersionIncompatible, bssci.POSIX_EPROTO) - } - - // Major version must match exactly (BSSCI 2.1) - if bsMajor != scMajor { - s.logger.Warn(bssci.LogBSSCIVersionIncompatible, - "bsVersion", version, - "bsMajor", bsMajor, - "scMajor", scMajor) - return bssci.NewCatalogError(bssci.ErrUnsupportedMajorVersion, bssci.POSIX_EPROTO) - } - - // Minor version must match for compatibility (BSSCI 2.2) - // Different minor versions should terminate the connection - if bsMinor != scMinor { - s.logger.Warn(bssci.LogBSSCIMinorVersionMismatch, - "bsMinor", bsMinor, - "scMinor", scMinor, - "bsVersion", version, - "scVersion", mioty.MIOTYProtocolVersion) - return bssci.NewCatalogError(bssci.ErrUnsupportedMinorVersion, bssci.POSIX_EPROTO) - } - - return nil -} - -// HandleResume checks sessionsByUUID and validates counters -// EXACT resume logic from server.go:694-750 -// Session parameter provides tenant context via session.ResolvedTenantID for DB queries -func (s *sessionService) HandleResume(session *bssci.Session, bsUUID []byte, scUUIDToMatch []byte, bsOpId, scOpId int64, bsEUI uint64) (*bssci.Session, error) { + // In-memory lookup first: same tenant, base station EUI, and BS UUID s.mu.RLock() - defer s.mu.RUnlock() - - // Look for an existing session with this BS EUI and BS UUID - // EXACT logic from server.go:696-722 - for scUUIDStr, sess := range s.sessionsByUUID { - // Check if this is the right session: - // 1. Same base station EUI - // 2. Same base station UUID (if we stored it) - // 3. If SC UUID was provided, it should match - if sess.BaseStationEUI == bsEUI { - if scUUIDToMatch != nil { - // BS provided the SC UUID - must match exactly (server.go:703-708) - if string(sess.SessionUUID) == string(scUUIDToMatch) { - // Validate counters per BSSCI §5.2 - // BS counter must not move backwards (monotonicity) - if bsOpId < sess.LastBsOpId { - s.logger.Warn("Resume rejected: BS operation counter moved backwards (in-memory)", - "bsEui", bsEUI, - "providedBsOpId", bsOpId, - "expectedBsOpId", sess.LastBsOpId, - "scUuid", fmt.Sprintf("%x", scUUIDToMatch)) - continue // Try next session - } - // SC counter validation: SC is authoritative (BSSCI §3.2) - // Accept if BS's scOpId >= sess.LastScOpId (stale or matches) - if scOpId < sess.LastScOpId { - s.logger.Warn("Resume rejected: BS claims SC counter beyond in-memory value", - "bsEui", bsEUI, - "providedScOpId", scOpId, - "expectedScOpId", sess.LastScOpId, - "scUuid", fmt.Sprintf("%x", scUUIDToMatch)) - continue // Try next session - } - if scOpId != sess.LastScOpId { - s.logger.Warn("Resume accepted with stale BS counter (in-memory, SC authoritative)", - "bsEui", bsEUI, - "bsReportedScOpId", scOpId, - "scAuthoritativeOpId", sess.LastScOpId, - "scUuid", fmt.Sprintf("%x", scUUIDToMatch)) - } - return sess, nil - } - } else if sess.BsUUID != nil && string(sess.BsUUID) == string(bsUUID) { - // No SC UUID provided, but BS UUID matches (server.go:709-712) - // Validate counters per BSSCI §5.2 - if bsOpId < sess.LastBsOpId { - s.logger.Warn("Resume rejected: BS operation counter moved backwards (in-memory)", - "bsEui", bsEUI, - "providedBsOpId", bsOpId, - "expectedBsOpId", sess.LastBsOpId, - "bsUuid", fmt.Sprintf("%x", bsUUID)) - continue // Try next session - } - // SC counter validation: SC is authoritative (BSSCI §3.2) - if scOpId < sess.LastScOpId { - s.logger.Warn("Resume rejected: BS claims SC counter beyond in-memory value", - "bsEui", bsEUI, - "providedScOpId", scOpId, - "expectedScOpId", sess.LastScOpId, - "bsUuid", fmt.Sprintf("%x", bsUUID)) - continue // Try next session - } - if scOpId != sess.LastScOpId { - s.logger.Warn("Resume accepted with stale BS counter (in-memory, SC authoritative)", - "bsEui", bsEUI, - "bsReportedScOpId", scOpId, - "scAuthoritativeOpId", sess.LastScOpId, - "bsUuid", fmt.Sprintf("%x", bsUUID)) - } - return sess, nil - } else if sess.BsUUID == nil { - // Older session without BS UUID stored, use SC UUID as key (server.go:713-719) - if scUUIDStr != "" { - // Validate counters per BSSCI §5.2 - if bsOpId < sess.LastBsOpId { - s.logger.Warn("Resume rejected: BS operation counter moved backwards (in-memory)", - "bsEui", bsEUI, - "providedBsOpId", bsOpId, - "expectedBsOpId", sess.LastBsOpId) - continue // Try next session - } - // SC counter validation: SC is authoritative (BSSCI §3.2) - if scOpId < sess.LastScOpId { - s.logger.Warn("Resume rejected: BS claims SC counter beyond in-memory value", - "bsEui", bsEUI, - "providedScOpId", scOpId, - "expectedScOpId", sess.LastScOpId) - continue // Try next session - } - if scOpId != sess.LastScOpId { - s.logger.Warn("Resume accepted with stale BS counter (in-memory, SC authoritative)", - "bsEui", bsEUI, - "bsReportedScOpId", scOpId, - "scAuthoritativeOpId", sess.LastScOpId) - } - return sess, nil - } - } + for _, sess := range s.sessionsByUUID { + if sess.BaseStationEUI != bsEUI || sess.BsUUID == nil || string(sess.BsUUID) != string(bsUUID) { + continue + } + if !s.resumeCountersConsistent(ctx, bsEUI, bsOpId, scOpId, sess.LastBsOpId, sess.LastScOpId) { + continue } + s.mu.RUnlock() + return sess, nil } + s.mu.RUnlock() - // Database fallback: try to restore session from persistence layer - // BSSCI §5.3/§5.3.1: session should be resumable across disconnects - ctx := context.Background() // No request context available in HandleResume + // Database fallback: only a disconnected, resumable session scoped by + // tenant, base station EUI, and snBsUuid qualifies (BSSCI §5.3.1) + var bsUUIDArray [16]byte + copy(bsUUIDArray[:], bsUUID) + euiBytes := make([]byte, 8) + binary.BigEndian.PutUint64(euiBytes, bsEUI) - // Try GetSessionByScUuid first (primary lookup) - if len(scUUIDToMatch) == 16 { - var scUUIDArray [16]byte - copy(scUUIDArray[:], scUUIDToMatch) - - dbSession, err := s.bsSessionRepo.GetSessionByScUUID(ctx, s.resolvedTenantID(session), scUUIDArray) - if err == nil && dbSession != nil { - // Validate operation counters per BSSCI §5.2 - // BS counter must not move backwards (equality allowed for idempotent resume) - if bsOpId < dbSession.SnBsOpId { - s.logger.WarnContext(ctx, "Resume rejected: BS operation counter moved backwards", - "bsEui", bsEUI, - "providedBsOpId", bsOpId, - "expectedBsOpId", dbSession.SnBsOpId, - "scUuid", fmt.Sprintf("%x", scUUIDArray)) - return nil, bssci.ErrResumeCounterMismatch - } - // SC counter validation: SC is authoritative for its own operation IDs (BSSCI §3.2) - // BS may have a stale (less negative) counter from a crash before SC persisted. - // Accept if BS's reported scOpId >= dbSession.SnScOpId (i.e., BS is stale or matches). - // Reject only if BS claims a more negative value than SC has (impossible without tampering). - if scOpId < dbSession.SnScOpId { - // BS claims to have seen a more negative SC opId than SC ever issued - reject - s.logger.WarnContext(ctx, "Resume rejected: BS claims SC counter beyond DB value", - "bsEui", bsEUI, - "providedScOpId", scOpId, - "expectedScOpId", dbSession.SnScOpId, - "scUuid", fmt.Sprintf("%x", scUUIDArray)) - return nil, bssci.ErrResumeCounterMismatch - } - if scOpId != dbSession.SnScOpId { - // BS has a stale counter - log but accept (SC will continue from its authoritative value) - s.logger.WarnContext(ctx, "Resume accepted with stale BS counter (SC is authoritative)", - "bsEui", bsEUI, - "bsReportedScOpId", scOpId, - "scAuthoritativeOpId", dbSession.SnScOpId, - "scUuid", fmt.Sprintf("%x", scUUIDArray)) - } - - // Counters valid - hydrate and return - restoredSession := s.hydrateSessionFromDB(dbSession, bsEUI) - - // Re-populate in-memory map (requires lock upgrade) - s.mu.RUnlock() - s.mu.Lock() - uuidKey := string(restoredSession.SessionUUID) - s.sessionsByUUID[uuidKey] = restoredSession - s.mu.Unlock() - s.mu.RLock() // Restore read lock for deferred RUnlock - - return restoredSession, nil - } + dbSession, err := s.bsSessionRepo.FindResumableSession(ctx, s.resolvedTenantID(session), euiBytes, bsUUIDArray) + if err != nil { + return nil, fmt.Errorf("resumable session lookup failed: %w", err) + } + if dbSession == nil { + return nil, nil // No match - fresh session } - // Try GetSessionByBsUuid fallback (secondary lookup) - if len(bsUUID) == 16 { - var bsUUIDArray [16]byte - copy(bsUUIDArray[:], bsUUID) + if !s.resumeCountersConsistent(ctx, bsEUI, bsOpId, scOpId, dbSession.SnBsOpId, dbSession.SnScOpId) { + return nil, bssci.ErrResumeCounterMismatch + } - dbSession, err := s.bsSessionRepo.GetSessionByBsUUID(ctx, s.resolvedTenantID(session), bsUUIDArray) - if err == nil && dbSession != nil { - // Validate operation counters per BSSCI §5.2 - // BS counter must not move backwards (equality allowed for idempotent resume) - if bsOpId < dbSession.SnBsOpId { - s.logger.WarnContext(ctx, "Resume rejected: BS operation counter moved backwards", - "bsEui", bsEUI, - "providedBsOpId", bsOpId, - "expectedBsOpId", dbSession.SnBsOpId, - "bsUuid", fmt.Sprintf("%x", bsUUIDArray)) - return nil, bssci.ErrResumeCounterMismatch - } - // SC counter validation: SC is authoritative for its own operation IDs (BSSCI §3.2) - // BS may have a stale (less negative) counter from a crash before SC persisted. - // Accept if BS's reported scOpId >= dbSession.SnScOpId (i.e., BS is stale or matches). - // Reject only if BS claims a more negative value than SC has (impossible without tampering). - if scOpId < dbSession.SnScOpId { - // BS claims to have seen a more negative SC opId than SC ever issued - reject - s.logger.WarnContext(ctx, "Resume rejected: BS claims SC counter beyond DB value", - "bsEui", bsEUI, - "providedScOpId", scOpId, - "expectedScOpId", dbSession.SnScOpId, - "bsUuid", fmt.Sprintf("%x", bsUUIDArray)) - return nil, bssci.ErrResumeCounterMismatch - } - if scOpId != dbSession.SnScOpId { - // BS has a stale counter - log but accept (SC will continue from its authoritative value) - s.logger.WarnContext(ctx, "Resume accepted with stale BS counter (SC is authoritative)", - "bsEui", bsEUI, - "bsReportedScOpId", scOpId, - "scAuthoritativeOpId", dbSession.SnScOpId, - "bsUuid", fmt.Sprintf("%x", bsUUIDArray)) - } + restoredSession := s.hydrateSessionFromDB(dbSession, bsEUI) - // Counters valid - hydrate and return - restoredSession := s.hydrateSessionFromDB(dbSession, bsEUI) + s.mu.Lock() + s.sessionsByUUID[string(restoredSession.SessionUUID)] = restoredSession + s.mu.Unlock() - // Re-populate in-memory map (requires lock upgrade) - s.mu.RUnlock() - s.mu.Lock() - uuidKey := string(restoredSession.SessionUUID) - s.sessionsByUUID[uuidKey] = restoredSession - s.mu.Unlock() - s.mu.RLock() // Restore read lock for deferred RUnlock + return restoredSession, nil +} - return restoredSession, nil - } +// resumeCountersConsistent applies the BSSCI §5.3.1 resume constraints +// against persisted counters. Absent constraints (nil) are not asserted; +// equality is always valid. +func (s *sessionService) resumeCountersConsistent(ctx context.Context, bsEUI uint64, bsOpId, scOpId *int64, knownBsOpId, knownScOpId int64) bool { + if bsOpId != nil && *bsOpId > knownBsOpId { + // The BS requires a minimum operation state the SC does not know + s.logger.WarnContext(ctx, "Resume rejected: required BS operation ID beyond persisted state", + "bsEui", bsEUI, + "requiredBsOpId", *bsOpId, + "persistedBsOpId", knownBsOpId) + return false } - - return nil, nil // No DB match - fallback to fresh session + if scOpId != nil && *scOpId < knownScOpId { + // The BS claims a more negative SC operation ID than the SC issued + s.logger.WarnContext(ctx, "Resume rejected: claimed SC operation ID beyond issued state", + "bsEui", bsEUI, + "claimedScOpId", *scOpId, + "issuedScOpId", knownScOpId) + return false + } + if scOpId != nil && *scOpId != knownScOpId { + s.logger.WarnContext(ctx, "Resume accepted with stale BS counter (SC is authoritative)", + "bsEui", bsEUI, + "bsReportedScOpId", *scOpId, + "scAuthoritativeOpId", knownScOpId) + } + return true } // hydrateSessionFromDB creates a bssci.Session from a database record @@ -547,16 +369,27 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess session.ResolvedTenantID = dbSession.TenantID } - // Build update request (persist negotiated protocol version) + // Resume activation is atomic: active status, resumability, connection + // metadata, encoding, negotiated version, organization, and a cleared + // end timestamp land in one update negotiated := session.NegotiatedVersion - activeStatus := models.SessionStatusActive // Transition from terminated to active on resume + activeStatus := models.SessionStatusActive + canResume := true + connectionID := session.ID updateReq := &models.BaseStationSessionUpdateRequest{ - Status: &activeStatus, // Fix: Update status from terminated to active on resume + Status: &activeStatus, + CanResume: &canResume, + ClearEndedAt: true, SnBsOpId: &session.LastBsOpId, SnScOpId: &session.LastScOpId, + ConnectionId: &connectionID, RemoteAddr: remoteAddr, - Encoding: &session.Encoding, // Update encoding if it changed - ProtocolVersion: &negotiated, // BSSCI §4-4.5: persist negotiated version + Encoding: &session.Encoding, + ProtocolVersion: &negotiated, // BSSCI §4-4.5: persist negotiated version + } + if session.OrganizationID != uuid.Nil { + orgID := session.OrganizationID + updateReq.OrganizationID = &orgID } err = s.bsSessionRepo.UpdateSession(ctx, s.resolvedTenantID(session), dbSession.ID, updateReq) @@ -594,6 +427,21 @@ func (s *sessionService) MarkHandshakeComplete(session *bssci.Session) { session.HandshakeComplete = true } +// MarkDisconnected marks an active session disconnected and resumable after +// unexpected connection loss. The repository update is guarded by the +// session's connection ID: if a reconnect already replaced this connection, +// zero rows match and the newer session stays active. +func (s *sessionService) MarkDisconnected(ctx context.Context, session *bssci.Session) error { + if session.DbSessionID == 0 { + return fmt.Errorf("cannot mark session disconnected: not persisted (DbSessionID=0)") + } + err := s.bsSessionRepo.MarkDisconnected(ctx, s.resolvedTenantID(session), session.DbSessionID, session.ID, time.Now()) + if err != nil { + return fmt.Errorf("failed to mark session disconnected: %w", err) + } + return nil +} + // RemoveSession cleans sessionsByUUID map on disconnect // Real cleanup from server.go:438 func (s *sessionService) RemoveSession(session *bssci.Session) { @@ -612,8 +460,8 @@ func (s *sessionService) RemoveSession(session *bssci.Session) { // UpdateEncoding persists the negotiated message encoding to the database // Called when encoding is detected on first message per BSSCI Section 1 -func (s *sessionService) UpdateEncoding(ctx context.Context, sessionID int64, encoding string) error { - return s.bsSessionRepo.UpdateEncoding(ctx, sessionID, encoding) +func (s *sessionService) UpdateEncoding(ctx context.Context, tenantID, sessionID int64, encoding string) error { + return s.bsSessionRepo.UpdateEncoding(ctx, tenantID, sessionID, encoding) } // UpdateSessionCounters persists operation ID counters to database (BSSCI §5.2). diff --git a/KC-Core/internal/services/bssci/session_service_test.go b/KC-Core/internal/services/bssci/session_service_test.go index e301d5f..065a740 100644 --- a/KC-Core/internal/services/bssci/session_service_test.go +++ b/KC-Core/internal/services/bssci/session_service_test.go @@ -2,133 +2,10 @@ package bssciservices import ( "context" - "fmt" - "testing" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" - "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -// TestValidateVersionMajorMinorCompatibility verifies BSSCI §2.1 and §2.2 compliance -// Addresses BSSCI-2.1-01, BSSCI-2.2-01, BSSCI-2.2-02 -func TestValidateVersionMajorMinorCompatibility(t *testing.T) { - svc := NewSessionService(nil, nil, nil, 0, &mockLogger{}) - - // Parse server version to derive test cases - serverMaj, serverMin, _, cerr := bssci.ParseVersion(mioty.MIOTYProtocolVersion) - require.Nil(t, cerr, "Server version should parse correctly") - - serverVer := mioty.MIOTYProtocolVersion - compatVer := fmt.Sprintf("%d.%d.%d", serverMaj, serverMin, 99) - incompatMajor := fmt.Sprintf("%d.%d.%d", serverMaj+1, 0, 0) - incompatMinor := fmt.Sprintf("%d.%d.%d", serverMaj, serverMin+1, 0) - - tests := []struct { - name string - ver string - expectError bool - errToken string - }{ - { - name: "exact_version_match", - ver: serverVer, - expectError: false, - }, - { - name: "patch_difference_compatible", - ver: compatVer, - expectError: false, - }, - { - name: "major_version_incompatible", - ver: incompatMajor, - expectError: true, - errToken: bssci.ErrUnsupportedMajorVersion, - }, - { - name: "minor_version_incompatible", - ver: incompatMinor, - expectError: true, - errToken: bssci.ErrUnsupportedMinorVersion, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := svc.ValidateVersion(tt.ver) - - if tt.expectError { - require.Error(t, err) - catErr, ok := err.(*bssci.CatalogError) - require.True(t, ok, "Error should be CatalogError") - assert.Equal(t, tt.errToken, catErr.Token) - assert.Equal(t, bssci.POSIX_EPROTO, catErr.Posix) - } else { - assert.NoError(t, err) - } - }) - } -} - -// TestValidateVersionPatchIgnored verifies BSSCI §2.3 compliance -// Addresses BSSCI-2.3-01, BSSCI-2.3-02 -func TestValidateVersionPatchIgnored(t *testing.T) { - svc := NewSessionService(nil, nil, nil, 0, &mockLogger{}) - - serverMaj, serverMin, _, cerr := bssci.ParseVersion(mioty.MIOTYProtocolVersion) - require.Nil(t, cerr) - - for _, patch := range []int{0, 1, 5, 99} { - ver := fmt.Sprintf("%d.%d.%d", serverMaj, serverMin, patch) - t.Run(fmt.Sprintf("patch_%d", patch), func(t *testing.T) { - err := svc.ValidateVersion(ver) - assert.NoError(t, err, "Patch diff should not cause incompatibility (BSSCI-2.3-01)") - }) - } -} - -// TestParseVersionFormat verifies semantic version parsing -func TestParseVersionFormat(t *testing.T) { - t.Run("valid_version", func(t *testing.T) { - maj, minor, pat, cerr := bssci.ParseVersion("1.0.0") - require.Nil(t, cerr) - assert.Equal(t, 1, maj) - assert.Equal(t, 0, minor) - assert.Equal(t, 0, pat) - }) - - t.Run("invalid_format", func(t *testing.T) { - _, _, _, cerr := bssci.ParseVersion("1.0") - require.NotNil(t, cerr, "Invalid format should return CatalogError") - assert.Equal(t, bssci.ErrInvalidVersionFormat, cerr.Token) - assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) - }) - - t.Run("invalid_major", func(t *testing.T) { - _, _, _, cerr := bssci.ParseVersion("v1.0.0") - require.NotNil(t, cerr) - assert.Equal(t, bssci.ErrInvalidMajorVersion, cerr.Token) - assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) - }) - - t.Run("invalid_minor", func(t *testing.T) { - _, _, _, cerr := bssci.ParseVersion("1.x.0") - require.NotNil(t, cerr) - assert.Equal(t, bssci.ErrInvalidMinorVersion, cerr.Token) - assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) - }) - - t.Run("invalid_patch", func(t *testing.T) { - _, _, _, cerr := bssci.ParseVersion("1.0.beta") - require.NotNil(t, cerr) - assert.Equal(t, bssci.ErrInvalidPatchVersion, cerr.Token) - assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) - }) -} - type mockLogger struct{} func (m *mockLogger) Debug(_ string, _ ...interface{}) {} diff --git a/KC-Core/internal/services/bssci/test_helpers.go b/KC-Core/internal/services/bssci/test_helpers.go index 3c6f83e..9fdd98d 100644 --- a/KC-Core/internal/services/bssci/test_helpers.go +++ b/KC-Core/internal/services/bssci/test_helpers.go @@ -3,6 +3,7 @@ package bssciservices import ( "context" "encoding/json" + "fmt" "sync" "time" @@ -231,9 +232,18 @@ func (m *mockBaseStationSessionRepo) UpdateSession(_ context.Context, _ int64, s if req.LastPingAt != nil { session.LastPingAt = req.LastPingAt } + if req.EndedAt != nil && req.ClearEndedAt { + return fmt.Errorf("update request cannot set and clear ended_at at once") + } if req.EndedAt != nil { session.EndedAt = req.EndedAt } + if req.ClearEndedAt { + session.EndedAt = nil + } + if req.CanResume != nil { + session.CanResume = *req.CanResume + } if req.ConnectionId != nil { session.ConnectionId = req.ConnectionId } @@ -278,7 +288,7 @@ func (m *mockBaseStationSessionRepo) UpdatePing(_ context.Context, _ int64, sess return nil } -func (m *mockBaseStationSessionRepo) UpdateEncoding(_ context.Context, sessionID int64, encoding string) error { +func (m *mockBaseStationSessionRepo) UpdateEncoding(_ context.Context, _, sessionID int64, encoding string) error { m.mu.Lock() defer m.mu.Unlock() @@ -334,21 +344,48 @@ func (m *mockBaseStationSessionRepo) CleanupExpiredSessions(_ context.Context, _ return 0, nil } -// UpdateCountersAndTimestamp updates operation counters by session UUID -func (m *mockBaseStationSessionRepo) UpdateCountersAndTimestamp(_ context.Context, sessionUUID [16]byte, bsOpId, scOpId int64) error { +// MarkDisconnected marks a session disconnected and resumable when the +// stored connection ID still matches (mirrors the conditional production +// update; zero matches is not an error) +func (m *mockBaseStationSessionRepo) MarkDisconnected(_ context.Context, tenantID, sessionID int64, connectionID string, endedAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + + session, ok := m.sessions[sessionID] + if !ok || session.TenantID != tenantID { + return nil + } + if session.ConnectionId == nil || *session.ConnectionId != connectionID { + return nil + } + session.Status = models.SessionStatusDisconnected + session.CanResume = true + ended := endedAt + session.EndedAt = &ended + session.UpdatedAt = time.Now() + return nil +} + +// FindResumableSession mirrors the production lookup: tenant + base station +// EUI + snBsUuid with status=disconnected and can_resume=true +func (m *mockBaseStationSessionRepo) FindResumableSession(_ context.Context, tenantID int64, _ []byte, snBsUUID [16]byte) (*models.BaseStationSession, error) { m.mu.Lock() defer m.mu.Unlock() - // Find session by UUID and update counters for _, session := range m.sessions { - if session.SnScUuid == sessionUUID { - session.SnBsOpId = bsOpId - session.SnScOpId = scOpId - session.UpdatedAt = time.Now() - return nil + if session.TenantID != tenantID { + continue } + if session.SnBsUuid != snBsUUID { + continue + } + if !session.CanResumeSession() { + continue + } + copied := *session + return &copied, nil } - return nil // Session not found - non-fatal for mock + return nil, nil } // mockPendingOperationRepository implements interfaces.PendingOperationRepository for testing diff --git a/KC-Core/internal/services/bssci/version_negotiator.go b/KC-Core/internal/services/bssci/version_negotiator.go new file mode 100644 index 0000000..608502d --- /dev/null +++ b/KC-Core/internal/services/bssci/version_negotiator.go @@ -0,0 +1,110 @@ +package bssciservices + +import ( + "context" + "fmt" + "sort" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" +) + +// supportedVersion is one parsed member of the service center's supported +// protocol version set. +type supportedVersion struct { + major, minor, patch int + canonical string +} + +// versionNegotiator selects the BSSCI protocol version for a connection from +// a configured supported-version set per rev1 §4.1-§4.3 and the §5.3.2 conRsp +// arbitration rule: the base station requests its newest supported version and +// the service center answers with the version it will speak. +type versionNegotiator struct { + logger logger.Logger + supported []supportedVersion // sorted descending by (major, minor, patch) +} + +// NewVersionNegotiator builds a negotiator from the supported protocol version +// set. The set must be non-empty, well-formed ("major.minor.patch"), and free +// of duplicates; it is parsed, normalized, and sorted once at construction. +func NewVersionNegotiator(supported []string, log logger.Logger) (bssci.VersionNegotiator, error) { + if log == nil { + return nil, fmt.Errorf("version negotiator requires a logger") + } + if len(supported) == 0 { + return nil, fmt.Errorf("version negotiator requires a non-empty supported version set") + } + + parsed := make([]supportedVersion, 0, len(supported)) + seen := make(map[string]bool, len(supported)) + for _, raw := range supported { + major, minor, patch, cerr := bssci.ParseVersion(raw) + if cerr != nil { + return nil, fmt.Errorf("malformed supported version %q: %s", raw, cerr.Token) + } + canonical := fmt.Sprintf("%d.%d.%d", major, minor, patch) + if seen[canonical] { + return nil, fmt.Errorf("duplicate supported version %q", canonical) + } + seen[canonical] = true + parsed = append(parsed, supportedVersion{major: major, minor: minor, patch: patch, canonical: canonical}) + } + + sort.Slice(parsed, func(i, j int) bool { + a, b := parsed[i], parsed[j] + if a.major != b.major { + return a.major > b.major + } + if a.minor != b.minor { + return a.minor > b.minor + } + return a.patch > b.patch + }) + + return &versionNegotiator{logger: log, supported: parsed}, nil +} + +// Negotiate selects the highest supported version with the requested major and +// a supported minor not above the requested minor. The result is always an +// exact member of the supported set - the requested patch is ignored for +// compatibility (rev1 §4.3) and never echoed unless the service center +// implements it. A base station requesting a newer minor is negotiated down to +// the selected version, which the conRsp carries per §5.3.2; the base station +// agrees by completing the operation or rejects it with an error. +func (n *versionNegotiator) Negotiate(ctx context.Context, requested string) (string, error) { + reqMajor, reqMinor, _, cerr := bssci.ParseVersion(requested) + if cerr != nil { + return "", cerr + } + + majorMatched := false + for _, v := range n.supported { + if v.major != reqMajor { + continue + } + majorMatched = true + if v.minor > reqMinor { + continue + } + if v.minor < reqMinor { + n.logger.InfoContext(ctx, bssci.LogBSSCIMinorVersionNegotiatedDown, + "requestedVersion", requested, + "selectedVersion", v.canonical) + } + return v.canonical, nil + } + + if !majorMatched { + n.logger.WarnContext(ctx, bssci.LogBSSCIVersionIncompatible, + "requestedVersion", requested) + return "", bssci.NewCatalogError(bssci.ErrUnsupportedMajorVersion, bssci.POSIX_EPROTO) + } + + // Same major, but every supported minor is above the requested minor: the + // service center cannot offer a version the base station did not request + // (BSSCI rev1 §4.2) + n.logger.WarnContext(ctx, bssci.LogBSSCIMinorVersionMismatch, + "requestedVersion", requested) + return "", bssci.NewCatalogError(bssci.ErrUnsupportedMinorVersion, bssci.POSIX_EPROTO) +} diff --git a/KC-Core/internal/services/bssci/version_negotiator_test.go b/KC-Core/internal/services/bssci/version_negotiator_test.go new file mode 100644 index 0000000..02876da --- /dev/null +++ b/KC-Core/internal/services/bssci/version_negotiator_test.go @@ -0,0 +1,178 @@ +package bssciservices + +import ( + "context" + "fmt" + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNewVersionNegotiatorSetValidation verifies the constructor rejects +// empty, malformed, and duplicate supported-version sets. +func TestNewVersionNegotiatorSetValidation(t *testing.T) { + tests := []struct { + name string + supported []string + expectErr bool + }{ + {name: "canonical_set", supported: []string{mioty.MIOTYProtocolVersion}, expectErr: false}, + {name: "multiple_versions", supported: []string{"1.0.0", "1.1.2"}, expectErr: false}, + {name: "empty_set", supported: nil, expectErr: true}, + {name: "malformed_member", supported: []string{"1.0"}, expectErr: true}, + {name: "signed_component", supported: []string{"1.-1.0"}, expectErr: true}, + {name: "duplicate_member", supported: []string{"1.0.0", "1.0.0"}, expectErr: true}, + {name: "duplicate_after_normalization", supported: []string{"1.0.0", "01.0.0"}, expectErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + neg, err := NewVersionNegotiator(tt.supported, &mockLogger{}) + if tt.expectErr { + require.Error(t, err) + assert.Nil(t, neg) + } else { + require.NoError(t, err) + assert.NotNil(t, neg) + } + }) + } + + t.Run("nil_logger", func(t *testing.T) { + _, err := NewVersionNegotiator([]string{"1.0.0"}, nil) + require.Error(t, err) + }) +} + +// TestNegotiateSelection verifies BSSCI rev1 §4.1-§4.3 and §5.3.2: the +// selected version is always an exact member of the supported set, newer +// minors negotiate down, lower minors and different majors are rejected. +// Addresses BSSCI-2.1-01, BSSCI-2.2-01, BSSCI-2.2-02. +func TestNegotiateSelection(t *testing.T) { + tests := []struct { + name string + supported []string + requested string + selected string + errToken string + }{ + {name: "exact_match", supported: []string{"1.0.0"}, requested: "1.0.0", selected: "1.0.0"}, + {name: "patch_difference_ignored", supported: []string{"1.0.0"}, requested: "1.0.99", selected: "1.0.0"}, + {name: "newer_minor_negotiated_down", supported: []string{"1.0.0"}, requested: "1.1.0", selected: "1.0.0"}, + {name: "much_newer_minor_negotiated_down", supported: []string{"1.0.0"}, requested: "1.5.7", selected: "1.0.0"}, + {name: "requested_patch_never_echoed", supported: []string{"1.0.0"}, requested: "1.0.5", selected: "1.0.0"}, + {name: "highest_supported_minor_wins", supported: []string{"1.0.0", "1.1.2"}, requested: "1.2.0", selected: "1.1.2"}, + {name: "equal_minor_selects_highest_patch", supported: []string{"1.1.0", "1.1.2"}, requested: "1.1.9", selected: "1.1.2"}, + {name: "lower_major_rejected", supported: []string{"1.0.0"}, requested: "0.9.0", errToken: bssci.ErrUnsupportedMajorVersion}, + {name: "higher_major_rejected", supported: []string{"1.0.0"}, requested: "2.0.0", errToken: bssci.ErrUnsupportedMajorVersion}, + {name: "lower_minor_than_all_supported_rejected", supported: []string{"1.1.0"}, requested: "1.0.0", errToken: bssci.ErrUnsupportedMinorVersion}, + {name: "malformed_request_rejected", supported: []string{"1.0.0"}, requested: "1.0", errToken: bssci.ErrInvalidVersionFormat}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + neg, err := NewVersionNegotiator(tt.supported, &mockLogger{}) + require.NoError(t, err) + + selected, negErr := neg.Negotiate(context.Background(), tt.requested) + if tt.errToken != "" { + require.Error(t, negErr) + catErr, ok := negErr.(*bssci.CatalogError) + require.True(t, ok, "Error should be CatalogError") + assert.Equal(t, tt.errToken, catErr.Token) + assert.Equal(t, bssci.POSIX_EPROTO, catErr.Posix) + return + } + require.NoError(t, negErr) + assert.Equal(t, tt.selected, selected) + assert.Contains(t, tt.supported, selected, + "selected version must be an exact member of the supported set") + }) + } +} + +// TestNegotiatePatchIgnored verifies BSSCI §4.3: patch differences never +// affect compatibility. Addresses BSSCI-2.3-01, BSSCI-2.3-02. +func TestNegotiatePatchIgnored(t *testing.T) { + neg, err := NewVersionNegotiator([]string{mioty.MIOTYProtocolVersion}, &mockLogger{}) + require.NoError(t, err) + + scMajor, scMinor, _, cerr := bssci.ParseVersion(mioty.MIOTYProtocolVersion) + require.Nil(t, cerr) + + for _, patch := range []int{0, 1, 5, 99} { + requested := fmt.Sprintf("%d.%d.%d", scMajor, scMinor, patch) + t.Run(fmt.Sprintf("patch_%d", patch), func(t *testing.T) { + selected, negErr := neg.Negotiate(context.Background(), requested) + require.NoError(t, negErr, "Patch diff should not cause incompatibility (BSSCI-2.3-01)") + assert.Equal(t, mioty.MIOTYProtocolVersion, selected) + }) + } +} + +// TestParseVersionFormat verifies strict version parsing: exactly three +// unsigned ASCII-decimal components; whitespace, signs, missing or extra +// components, and overflow are rejected with component-specific tokens. +func TestParseVersionFormat(t *testing.T) { + t.Run("valid_version", func(t *testing.T) { + maj, minor, pat, cerr := bssci.ParseVersion("1.0.0") + require.Nil(t, cerr) + assert.Equal(t, 1, maj) + assert.Equal(t, 0, minor) + assert.Equal(t, 0, pat) + }) + + formatCases := map[string]string{ + "missing_component": "1.0", + "extra_component": "1.0.0.0", + "empty_string": "", + } + for name, input := range formatCases { + t.Run(name, func(t *testing.T) { + _, _, _, cerr := bssci.ParseVersion(input) + require.NotNil(t, cerr, "Invalid format should return CatalogError") + assert.Equal(t, bssci.ErrInvalidVersionFormat, cerr.Token) + assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) + }) + } + + majorCases := map[string]string{ + "non_numeric_major": "v1.0.0", + "signed_major": "+1.0.0", + "negative_major": "-1.0.0", + "whitespace_major": " 1.0.0", + "empty_major": ".0.0", + "overflow_major": "99999999999999999999.0.0", + } + for name, input := range majorCases { + t.Run(name, func(t *testing.T) { + _, _, _, cerr := bssci.ParseVersion(input) + require.NotNil(t, cerr) + assert.Equal(t, bssci.ErrInvalidMajorVersion, cerr.Token) + assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) + }) + } + + t.Run("invalid_minor", func(t *testing.T) { + _, _, _, cerr := bssci.ParseVersion("1.x.0") + require.NotNil(t, cerr) + assert.Equal(t, bssci.ErrInvalidMinorVersion, cerr.Token) + assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) + }) + + t.Run("invalid_patch", func(t *testing.T) { + _, _, _, cerr := bssci.ParseVersion("1.0.beta") + require.NotNil(t, cerr) + assert.Equal(t, bssci.ErrInvalidPatchVersion, cerr.Token) + assert.Equal(t, bssci.POSIX_EPROTO, cerr.Posix) + }) + + t.Run("trailing_whitespace_patch", func(t *testing.T) { + _, _, _, cerr := bssci.ParseVersion("1.0.0 ") + require.NotNil(t, cerr) + assert.Equal(t, bssci.ErrInvalidPatchVersion, cerr.Token) + }) +} diff --git a/KC-Core/internal/services/certificates/service.go b/KC-Core/internal/services/certificates/service.go index 3c0b63e..865110a 100644 --- a/KC-Core/internal/services/certificates/service.go +++ b/KC-Core/internal/services/certificates/service.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "crypto/x509" + "encoding/binary" "encoding/hex" "encoding/json" "encoding/pem" @@ -22,6 +23,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/crypto" pkggrpc "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/mioty" + "github.com/Kiloiot/kilo-service-center/KC-DB/common/validation" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/google/uuid" ) @@ -117,11 +120,14 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer "name", req.BaseStationName, "validity_days", req.ValidityDays) - // Validate Base Station EUI format - if !isValidEUI(req.BsEUI) { - s.logger.WarnContext(ctx, pkggrpc.LogCertInvalidEUI, "bs_eui", req.BsEUI) + // Validate and normalize the Base Station EUI (accepts dashed, colon-separated, or plain 16-hex) + euiValue, euiErr := validation.ParseEUI(req.BsEUI) + if euiErr != nil { + s.logger.WarnContext(ctx, pkggrpc.LogCertInvalidEUI, "bs_eui", req.BsEUI, "error", euiErr) return nil, fmt.Errorf("%s: %s", pkggrpc.ErrTokenInvalidBasestationEUIFormat, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenInvalidBasestationEUIFormat)) } + // Canonical uppercase-dashed form used as the certificate CN and in stored metadata + bsEUIDashed := mioty.FormatEUI64Dashed(euiValue) // Validate validity days (max 3 years = 1095 days) if req.ValidityDays < 1 || req.ValidityDays > 1095 { @@ -165,7 +171,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCACertReadFailed, err) } - if err := os.WriteFile(caCertDst, caCertData, 0600); err != nil { + if err := os.WriteFile(caCertDst, caCertData, 0600); err != nil { //nolint:gosec // G703: path built from configured cert dir and canonically validated EUI s.logger.ErrorContext(ctx, pkggrpc.LogCertCACertCopyFailed, "error", err) if rmErr := os.RemoveAll(certDir); rmErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) @@ -188,7 +194,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCAKeyReadFailed, err) } - if err := os.WriteFile(caKeyDst, caKeyData, 0600); err != nil { + if err := os.WriteFile(caKeyDst, caKeyData, 0600); err != nil { //nolint:gosec // G703: path built from configured cert dir and canonically validated EUI s.logger.ErrorContext(ctx, pkggrpc.LogCertCAKeyCopyFailed, "error", err) if rmErr := os.RemoveAll(certDir); rmErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) @@ -200,7 +206,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer cmd := exec.Command(s.certGenPath, "-dir", certDir, "-days", fmt.Sprintf("%d", req.ValidityDays), - "-client", req.BsEUI, + "-client", bsEUIDashed, "-client-only", ) @@ -241,7 +247,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer // Save certificate info for later retrieval certInfoData := map[string]interface{}{ - "bsEui": req.BsEUI, + "bsEui": bsEUIDashed, "createdAt": time.Now().Format(time.RFC3339), "expiresAt": certExpiryStr, "validityDays": req.ValidityDays, @@ -253,11 +259,9 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer // Persist certs to base station when dependencies are configured if s.bsRepo != nil && s.keyEncryptor != nil && req.TenantID > 0 { - euiBytes, parseErr := hex.DecodeString(strings.ReplaceAll(req.BsEUI, "-", "")) - if parseErr == nil && len(euiBytes) == 8 { - if persistErr := s.persistCertsToBaseStation(ctx, certDir, euiBytes, req.TenantID, certExpiryTime); persistErr != nil { - s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", persistErr) - } + euiBytes := binary.BigEndian.AppendUint64(nil, euiValue) + if persistErr := s.persistCertsToBaseStation(ctx, certDir, euiBytes, req.TenantID, certExpiryTime); persistErr != nil { + s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", persistErr) } } else if s.bsRepo != nil && s.keyEncryptor == nil && req.TenantID > 0 { s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", pkggrpc.ErrTokenServiceNotConfigured) @@ -275,7 +279,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer } return &grpcservices.CertificateResponse{ - BsEUI: req.BsEUI, + BsEUI: bsEUIDashed, ServiceCenterURL: serviceCenterURL, DownloadURLs: downloadUrls, ExpiresAt: &expiresAt, @@ -578,27 +582,6 @@ func (s *Service) getCertificateInfo(certPath string) *certInfo { } } -// isValidEUI validates the Base Station EUI format (XX-XX-XX-XX-XX-XX-XX-XX) -func isValidEUI(eui string) bool { - if len(eui) != 23 { - return false - } - - for i, ch := range eui { - if (i+1)%3 == 0 { - if ch != '-' { - return false - } - } else { - if (ch < '0' || ch > '9') && (ch < 'A' || ch > 'F') && (ch < 'a' || ch > 'f') { - return false - } - } - } - - return true -} - // GetStoredCertificate retrieves TLS certificates stored in base station record. func (s *Service) GetStoredCertificate(ctx context.Context, tenantID int64, bsEui []byte, certType string) ([]byte, string, error) { if s.bsRepo == nil { diff --git a/KC-Core/internal/services/certificates/service_test.go b/KC-Core/internal/services/certificates/service_test.go index 6ceee25..10d96c3 100644 --- a/KC-Core/internal/services/certificates/service_test.go +++ b/KC-Core/internal/services/certificates/service_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/Kiloiot/kilo-service-center/KC-Core/internal/services/grpcservices" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/config" pkggrpc "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" @@ -73,6 +74,74 @@ func TestDownloadCertificateByID_ShortCertID(t *testing.T) { } } +// newGenerateTestService builds a Service whose certgen binary is absent so that +// GenerateCertificate proceeds past EUI validation but fails at the generator check. +func newGenerateTestService(t *testing.T) *Service { + t.Helper() + tmpDir := t.TempDir() + return &Service{ + config: &config.Config{}, + logger: &mockLogger{}, + certGenPath: filepath.Join(tmpDir, "certgen-missing"), + certsDir: tmpDir, + tempDir: filepath.Join(tmpDir, "temp"), + serverValidityDays: config.DefaultCertificatesServerValidityDays, + protocolConfig: &config.ProtocolConfig{}, + } +} + +func TestGenerateCertificate_RejectsMalformedEUI(t *testing.T) { + svc := newGenerateTestService(t) + ctx := testutil.TestContext() + + _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "not-a-valid-eui", + ValidityDays: 365, + }) + if err == nil { + t.Fatal("expected error for malformed EUI, got nil") + } + if !strings.Contains(err.Error(), pkggrpc.ErrTokenInvalidBasestationEUIFormat) { + t.Errorf("error = %q, want it to contain %q", err.Error(), pkggrpc.ErrTokenInvalidBasestationEUIFormat) + } +} + +func TestGenerateCertificate_AcceptsDashedHighBitEUI(t *testing.T) { + svc := newGenerateTestService(t) + ctx := testutil.TestContext() + + _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "CA-FE-CA-FE-CA-FE-CA-FE", + ValidityDays: 365, + }) + if err == nil { + t.Fatal("expected generator-not-found error, got nil") + } + // EUI validation must succeed; the failure comes from the absent certgen binary. + if strings.Contains(err.Error(), pkggrpc.ErrTokenInvalidBasestationEUIFormat) { + t.Errorf("dashed high-bit EUI was rejected as invalid: %v", err) + } + if !strings.Contains(err.Error(), pkggrpc.ErrTokenCertGeneratorNotFound) { + t.Errorf("error = %q, want it to contain %q", err.Error(), pkggrpc.ErrTokenCertGeneratorNotFound) + } +} + +func TestGenerateCertificate_AcceptsPlainHexEUI(t *testing.T) { + svc := newGenerateTestService(t) + ctx := testutil.TestContext() + + _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "cafecafecafecafe", + ValidityDays: 365, + }) + if err == nil { + t.Fatal("expected generator-not-found error, got nil") + } + if strings.Contains(err.Error(), pkggrpc.ErrTokenInvalidBasestationEUIFormat) { + t.Errorf("plain 16-hex EUI was rejected as invalid: %v", err) + } +} + func TestGetStoredCertificate_InvalidCertType(t *testing.T) { svc := &Service{ logger: &mockLogger{}, diff --git a/KC-Core/pkg/basestation/connection_manager.go b/KC-Core/pkg/basestation/connection_manager.go index 689a643..bb06085 100644 --- a/KC-Core/pkg/basestation/connection_manager.go +++ b/KC-Core/pkg/basestation/connection_manager.go @@ -80,14 +80,27 @@ type ConnectionManager struct { mqttHandler *MQTTHandler store Store eventRecorder EventRecorder + logger logger.Logger } +// Connection status field keys shared by status updates and events. +const ( + fieldKeyIsOnline = "is_online" + fieldKeyConnectionType = "connection_type" + fieldKeySessionID = "session_id" + fieldKeyLastSeenAt = "last_seen_at" +) + // Store interface for database operations type Store interface { RegisterBaseStation(ctx context.Context, reg *Registration) error GetBaseStation(ctx context.Context, eui [8]byte) (*BaseStation, error) GetBaseStationGlobal(ctx context.Context, eui [8]byte) (*BaseStation, error) UpdateConnectionStatus(ctx context.Context, eui [8]byte, status *ConnectionStatus) error + // DisconnectIfCurrent marks the base station offline only while the + // stored session identity still matches the disconnecting connection. + // Returns false without error when a newer connection owns the row. + DisconnectIfCurrent(ctx context.Context, eui [8]byte, connectionID string, lastSeen time.Time) (bool, error) ListBaseStations(ctx context.Context) ([]*BaseStation, error) } @@ -121,6 +134,7 @@ func NewConnectionManager(store Store, eventRecorder EventRecorder, log logger.L eventRecorder: eventRecorder, bssciHandler: NewBSSCIHandler(), mqttHandler: NewMQTTHandler(log), + logger: log, } } @@ -148,7 +162,7 @@ func (cm *ConnectionManager) RegisterBaseStation(ctx context.Context, reg *Regis } if err := cm.eventRecorder.RecordEvent(ctx, reg.EUI, models.EventTypeBSRegistered, eventData); err != nil { // Log error but don't fail registration - fmt.Printf("Failed to record registration event: %v\n", err) + cm.logger.WarnContext(ctx, "Failed to record registration event", "error", err) } // Initialize connection based on type @@ -234,14 +248,14 @@ func (cm *ConnectionManager) UpdateConnectionStatus(ctx context.Context, eui [8] bs, err := cm.store.GetBaseStation(ctx, eui) if err != nil { // Log error but continue with event recording - fmt.Printf("Failed to get base station details: %v\n", err) + cm.logger.WarnContext(ctx, "Failed to get base station details", "error", err) } // Record status change event eventData := map[string]interface{}{ - "is_online": status.IsOnline, - "connection_type": status.ConnectionType, - "session_id": status.SessionID, + fieldKeyIsOnline: status.IsOnline, + fieldKeyConnectionType: status.ConnectionType, + fieldKeySessionID: status.SessionID, } // Add base station name if available @@ -257,6 +271,25 @@ func (cm *ConnectionManager) UpdateConnectionStatus(ctx context.Context, eui [8] return cm.eventRecorder.RecordEvent(ctx, eui, eventType, eventData) } +// DisconnectBaseStationIfCurrent marks the base station offline only while +// the stored connection still belongs to the disconnecting session. When a +// reconnect has already replaced the connection, the newer session keeps the +// base station online and no update happens (not an error). +func (cm *ConnectionManager) DisconnectBaseStationIfCurrent(ctx context.Context, eui [8]byte, connectionID string) error { + acted, err := cm.store.DisconnectIfCurrent(ctx, eui, connectionID, time.Now()) + if err != nil { + return fmt.Errorf("failed to disconnect base station: %w", err) + } + if !acted { + return nil + } + eventData := map[string]interface{}{ + fieldKeyIsOnline: false, + fieldKeySessionID: connectionID, + } + return cm.eventRecorder.RecordEvent(ctx, eui, models.EventTypeBaseStationOffline, eventData) +} + // validateRegistration validates the registration parameters func (cm *ConnectionManager) validateRegistration(reg *Registration) error { if reg.Name == "" { diff --git a/KC-Core/pkg/basestation/repository_adapter.go b/KC-Core/pkg/basestation/repository_adapter.go index 9eb53cd..536098b 100644 --- a/KC-Core/pkg/basestation/repository_adapter.go +++ b/KC-Core/pkg/basestation/repository_adapter.go @@ -84,12 +84,12 @@ func (ra *RepositoryAdapter) RegisterBaseStation(ctx context.Context, reg *Regis if getErr == nil && existingBS != nil { // Update existing basestation updates := map[string]interface{}{ - "name": baseStation.Name, - "connection_type": baseStation.ConnectionType, - "is_online": true, - "vendor": baseStation.Vendor, - "model": baseStation.Model, - "sw_version": baseStation.Version, + "name": baseStation.Name, + fieldKeyConnectionType: baseStation.ConnectionType, + fieldKeyIsOnline: true, + "vendor": baseStation.Vendor, + "model": baseStation.Model, + "sw_version": baseStation.Version, } // Unconditional override for BSSCI connections in updates map @@ -213,9 +213,9 @@ func (ra *RepositoryAdapter) UpdateConnectionStatus(ctx context.Context, eui [8] // Update connection status updates := map[string]interface{}{ - "is_online": status.IsOnline, - "last_seen_at": status.LastSeen, - "connection_type": string(status.ConnectionType), + fieldKeyIsOnline: status.IsOnline, + fieldKeyLastSeenAt: status.LastSeen, + fieldKeyConnectionType: string(status.ConnectionType), } if status.SessionID != "" { @@ -225,6 +225,33 @@ func (ra *RepositoryAdapter) UpdateConnectionStatus(ctx context.Context, eui [8] return ra.repo.Update(ctx, tenant, dbBaseStation.ID, updates) } +// DisconnectIfCurrent implements the conditional offline transition: the +// update applies only while the stored session_uuid still belongs to the +// disconnecting connection, so late cleanup from a replaced connection never +// marks the newer session offline. +func (ra *RepositoryAdapter) DisconnectIfCurrent(ctx context.Context, eui [8]byte, connectionID string, lastSeen time.Time) (bool, error) { + tenant := ra.effectiveTenant(ctx) + dbBaseStation, err := ra.repo.GetByEUI(ctx, tenant, eui[:]) + if err != nil { + return false, fmt.Errorf("failed to get basestation: %w", err) + } + if dbBaseStation == nil { + return false, nil + } + if dbBaseStation.SessionUUID == nil || *dbBaseStation.SessionUUID != connectionID { + return false, nil + } + + updates := map[string]interface{}{ + fieldKeyIsOnline: false, + fieldKeyLastSeenAt: lastSeen, + } + if err := ra.repo.Update(ctx, tenant, dbBaseStation.ID, updates); err != nil { + return false, err + } + return true, nil +} + // ListBaseStations implements BaseStationStore interface func (ra *RepositoryAdapter) ListBaseStations(ctx context.Context) ([]*BaseStation, error) { filter := &models.BaseStationFilter{ diff --git a/KC-Core/pkg/bssci/connect_handler_test.go b/KC-Core/pkg/bssci/connect_handler_test.go index d82e13b..74b8155 100644 --- a/KC-Core/pkg/bssci/connect_handler_test.go +++ b/KC-Core/pkg/bssci/connect_handler_test.go @@ -213,14 +213,15 @@ func TestHandleConnectCompleteUnregisteredBaseStationUsesErrorCatalog(t *testing SessionUUID: make([]byte, 16), // Required for handleConnectComplete } - // Create connectComplete message (opId must be 0 per BSSCI §3.3) - msg := &bssci.Message{ - OpId: 0, - Command: "conCmp", + // Registration is validated during the connect request, before conRsp + connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, + "bsEui": int64(0x0123456789ABCDEF), + "bidi": true, } - - // Execute: Call handleConnectComplete (where GetBaseStation is called) - _ = server.CallHandleConnectComplete(session, msg, map[string]interface{}{}) + msg := &bssci.Message{OpId: 0, Command: "con", Data: connectData} + require.NoError(t, server.CallHandleConnect(session, msg, connectData), + "rejected connect awaits errorAck instead of closing") // Primary verification: Error was sent to base station using catalog // (This is the key requirement - using error catalog instead of literals) @@ -233,23 +234,14 @@ func TestHandleConnectCompleteUnregisteredBaseStationUsesErrorCatalog(t *testing assert.Equal(t, expectedMessage, mockConn.lastErrorMessage, "Error message should come from error catalog, not be hardcoded") - // Verify: GetBaseStationGlobal was called (global lookup during connect) - assert.True(t, mockConnectionSvc.globalCalled, "GetBaseStationGlobal should be called during connect complete") - - t.Logf("PASS: GetBaseStationGlobal called: %v", mockConnectionSvc.globalCalled) - t.Logf("PASS: Error code sent: %d (POSIX_EPERM)", mockConn.lastErrorCode) - t.Logf("PASS: Catalog message: %s", mockConn.lastErrorMessage) -} - -// TestHandleConnectRegisteredBaseStationSuccess verifies successful connect path -func TestHandleConnectRegisteredBaseStationSuccess(t *testing.T) { - t.Skip("Requires full database setup - covered by existing integration tests") + // Verify: GetBaseStationGlobal was called before any conRsp + assert.True(t, mockConnectionSvc.globalCalled, "GetBaseStationGlobal should be called during connect") } -// TestConnectCompleteRepairsSessionTenant verifies that handleConnectComplete repairs -// the session's ResolvedTenantID when GetBaseStationGlobal returns a base station -// registered under a different tenant than the server's default. -func TestConnectCompleteRepairsSessionTenant(t *testing.T) { +// TestConnectAdoptsRegisteredTenantCommunityFallback verifies that with +// organization enforcement disabled, the connect request adopts the base +// station's registered tenant before the response is offered. +func TestConnectAdoptsRegisteredTenantCommunityFallback(t *testing.T) { testLogger := &NopLogger{} // Mock returns base station registered under tenant 4 (not server default 1) @@ -292,23 +284,83 @@ func TestConnectCompleteRepairsSessionTenant(t *testing.T) { IsResumed: true, // Skip PersistSession DbSessionID assignment (avoids nil storage in loadPendingOperations) } - msg := &bssci.Message{OpId: 0, Command: "conCmp"} - _ = server.CallHandleConnectComplete(session, msg, map[string]interface{}{}) + connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, + "bsEui": int64(0x0123456789ABCDEF), + "bidi": true, + } + conMsg := &bssci.Message{OpId: 0, Command: "con", Data: connectData} + require.NoError(t, server.CallHandleConnect(session, conMsg, connectData)) // GetBaseStationGlobal was called (not tenant-scoped GetBaseStation) assert.True(t, mockConnectionSvc.globalCalled, - "handleConnectComplete must use GetBaseStationGlobal for tenant-agnostic lookup") + "connect must use GetBaseStationGlobal for tenant-agnostic lookup") - // Session tenant repaired from 1 → 4 + // Community fallback adopts the registered tenant 1 → 4 assert.Equal(t, int64(4), session.ResolvedTenantID, - "Session ResolvedTenantID must be repaired to base station's registered tenant") + "Session ResolvedTenantID must adopt the base station's registered tenant") // No error sent (base station found) assert.False(t, mockConn.errorSent, "No error should be sent for registered base station") + msg := &bssci.Message{OpId: 0, Command: "conCmp"} + _ = server.CallHandleConnectComplete(session, msg, map[string]interface{}{}) + // RegisterConnection was called (connection status updated) assert.True(t, mockConnectionSvc.registerCalled, - "RegisterConnection must be called after successful lookup") + "RegisterConnection must be called after successful completion") +} + +// TestConnectTenantMismatchRejected verifies that with organization +// enforcement enabled, a certificate tenant that does not match the base +// station's registered tenant is rejected without revealing that the EUI +// exists under another tenant. +func TestConnectTenantMismatchRejected(t *testing.T) { + testLogger := &NopLogger{} + + mockConnectionSvc := &mockConnectionService{tenantID: 4} + + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(testLogger, nil) + + server := bssci.NewTestServer( + testLogger, nil, nil, 1, + sessionSvc, downlinkSvc, statusSvc, mockConnectionSvc, + broadcaster, queueSerializer, auditLogger, tenantResolver, + ) + server.SetConfig(&bssci.Config{ + ServiceCenterEUI: bssci.TestScEui01, + Vendor: "Test Vendor", + Model: "Test Model", + Name: "Test SC", + SoftwareVersion: "1.0.0", + OrgEnforcementEnabled: true, + }) + server.SetConnectionManager(nil) + + mockConn := &mockConnForConnect{} + session := &bssci.Session{ + ID: "test-tenant-mismatch", + BaseStationEUI: bssci.TestBsEui01, + Conn: mockConn, + SessionUUID: make([]byte, 16), + ResolvedTenantID: 1, // Certificate resolved to tenant 1; BS registered under 4 + } + + connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, + "bsEui": int64(0x0123456789ABCDEF), + "bidi": true, + } + conMsg := &bssci.Message{OpId: 0, Command: "con", Data: connectData} + require.NoError(t, server.CallHandleConnect(session, conMsg, connectData), + "rejected connect awaits errorAck instead of closing") + + require.True(t, mockConn.errorSent, "Tenant mismatch must be rejected") + assert.Equal(t, bssci.POSIX_EPERM, mockConn.lastErrorCode) + assert.Equal(t, bssci.ResolveErrorMessage(bssci.ErrBaseStationNotRegistered), mockConn.lastErrorMessage, + "Rejection must not reveal that the EUI exists under another tenant") + assert.Equal(t, int64(1), session.ResolvedTenantID, + "Certificate tenant must not be overwritten on rejection") } // TestConnectCompleteDefaultTenantUnchanged verifies that when a base station @@ -356,6 +408,14 @@ func TestConnectCompleteDefaultTenantUnchanged(t *testing.T) { IsResumed: true, } + connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, + "bsEui": int64(0x0123456789ABCDEF), + "bidi": true, + } + conMsg := &bssci.Message{OpId: 0, Command: "con", Data: connectData} + require.NoError(t, server.CallHandleConnect(session, conMsg, connectData)) + msg := &bssci.Message{OpId: 0, Command: "conCmp"} _ = server.CallHandleConnectComplete(session, msg, map[string]interface{}{}) @@ -399,6 +459,7 @@ func TestConnectHandler_ValidGeoLocation_PersistsToDB(t *testing.T) { // handleConnect parses geoLocation from conRsp data into session.GeoLocation connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, "bsEui": int64(0x0123456789ABCDEF), "bidi": true, "geoLocation": []interface{}{float64(48.8566), float64(2.3522), float64(35.0)}, @@ -470,6 +531,7 @@ func TestConnectHandler_InvalidGeoLocation_NoPersistence(t *testing.T) { // geoLocation as a string (wrong type — should be array) connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, "bsEui": int64(0x0123456789ABCDEF), "bidi": true, "geoLocation": "48.8566,2.3522,35.0", @@ -528,6 +590,7 @@ func TestConnectHandler_OutOfRangeGeoLocation_NoPersistence(t *testing.T) { // Latitude = 200.0 exceeds LatitudeMax (90.0) connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, "bsEui": int64(0x0123456789ABCDEF), "bidi": true, "geoLocation": []interface{}{float64(200.0), float64(13.4050), float64(34.0)}, diff --git a/KC-Core/pkg/bssci/constants.go b/KC-Core/pkg/bssci/constants.go index 323501e..4f80692 100644 --- a/KC-Core/pkg/bssci/constants.go +++ b/KC-Core/pkg/bssci/constants.go @@ -16,6 +16,33 @@ const ( EncodingJSON = "json" ) +// Timing defaults applied when the corresponding protocol configuration is +// absent (tests and minimal setups); production values come from +// protocol.ack_timeout, protocol.duplicate_window, and +// protocol.bsci_certificate_poll_interval. +const ( + // defaultOperationAckTimeout bounds handshake waits (conCmp/errorAck after conRsp or a connect-stage error) + defaultOperationAckTimeout = 30 * time.Second + + // defaultDuplicateWindow is the uplink deduplication window per MIOTY spec + defaultDuplicateWindow = 5 * time.Minute + + // defaultCertificatePollInterval is the certificate change poll interval + defaultCertificatePollInterval = 10 * time.Second +) + +// Exact float integer bounds for wire numeric coercion. +// IEEE 754 floating-point values represent integers exactly only up to their +// mantissa width; values beyond these bounds silently lose precision and must +// be rejected when converting to integer protocol fields (e.g. EUI-64). +const ( + // maxExactFloat64Integer is the largest integer exactly representable in a float64 (2^53) + maxExactFloat64Integer = uint64(1) << 53 + + // maxExactFloat32Integer is the largest integer exactly representable in a float32 (2^24) + maxExactFloat32Integer = uint64(1) << 24 +) + // Propagate Status Constants // BSSCI Sections 5.6-5.7, 5.9 - Endpoint telemetry propagation status values // These are re-exported from the endpoint package to avoid import cycles diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index ca0f336..121490a 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -11,17 +11,23 @@ import ( "github.com/google/uuid" ) +// VersionNegotiator selects the BSSCI protocol version for a connection per +// rev1 §4.1-§4.3 and the §5.3.2 conRsp arbitration rule: the base station +// requests its newest supported version; the service center answers with the +// version it will speak (always an exact member of its supported set), and the +// base station agrees by completing the operation or rejects it with an error. +type VersionNegotiator interface { + Negotiate(ctx context.Context, requested string) (selected string, err error) +} + // SessionService handles connect/resume with REAL persistence (server.go:611-1004) // Preserves: sessionsByUUID map, DbSessionId, HandshakeComplete, DB persistence type SessionService interface { - // ValidateVersion checks string version (server.go:622-637) - ValidateVersion(version string) error - - // HandleResume checks sessionsByUUID, validates counters (server.go:667-746) - // Preserves exact resume logic including scUUIDToMatch handling - // Returns existing session if resume valid, nil otherwise - // Session parameter provides tenant context via session.ResolvedTenantID for DB queries - HandleResume(session *Session, bsUUID []byte, scUUIDToMatch []byte, bsOpId, scOpId int64, bsEUI uint64) (*Session, error) + // HandleResume validates a session resume request (BSSCI §5.3.1): resume + // identity is snBsUuid scoped by tenant and base station EUI. The optional + // snBsOpId/snScOpId constraints are pointers - absent means the constraint + // is not asserted. Returns the resumable session or nil for a fresh start. + HandleResume(ctx context.Context, session *Session, bsUUID []byte, bsOpId, scOpId *int64, bsEUI uint64) (*Session, error) // PersistSession writes to basestation_sessions table (server.go:852-950) // Uses real *sql.DB, updates DbSessionId, handles resume UPDATE vs new INSERT @@ -42,8 +48,13 @@ type SessionService interface { TerminateSession(ctx context.Context, session *Session) error // UpdateEncoding persists negotiated message encoding to database (BSSCI Section 1) - // Called when encoding is detected on first message - UpdateEncoding(ctx context.Context, sessionID int64, encoding string) error + // Called when encoding is detected on first message; tenant-scoped + UpdateEncoding(ctx context.Context, tenantID, sessionID int64, encoding string) error + + // MarkDisconnected marks an active session disconnected and resumable + // after unexpected connection loss, guarded by the session's connection + // ID so a newer connection is never marked offline by stale cleanup + MarkDisconnected(ctx context.Context, session *Session) error // UpdateSessionCounters persists operation ID counters to database (BSSCI §5.2) // Called immediately after successful SC-initiated operations. diff --git a/KC-Core/pkg/bssci/conversions.go b/KC-Core/pkg/bssci/conversions.go index 83ac48f..13d80f5 100644 --- a/KC-Core/pkg/bssci/conversions.go +++ b/KC-Core/pkg/bssci/conversions.go @@ -1,6 +1,11 @@ // Package bssci type conversion helpers with overflow protection package bssci +import ( + "strconv" + "strings" +) + // safeUint8 converts int64 to uint8 with bounds checking for BSSCI field assignments // Returns (converted value, error token if overflow) // @@ -37,3 +42,45 @@ func safeUint64(v int64, _ string) (uint64, string) { } return uint64(v), "" } + +// ParseVersion parses a BSSCI protocol version string "major.minor.patch" +// (BSSCI rev1 §4). Exactly three unsigned ASCII-decimal components are +// required; whitespace, signs, empty or extra components, and values +// overflowing the int range are rejected. Returns a specific CatalogError per +// component to preserve diagnostic precision. +func ParseVersion(version string) (major, minor, patch int, cerr *CatalogError) { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return 0, 0, 0, NewCatalogError(errInvalidVersionFormat, POSIX_EPROTO) + } + + componentTokens := [3]string{errInvalidMajorVersion, errInvalidMinorVersion, errInvalidPatchVersion} + var values [3]int + for i, part := range parts { + v, ok := parseVersionComponent(part) + if !ok { + return 0, 0, 0, NewCatalogError(componentTokens[i], POSIX_EPROTO) + } + values[i] = v + } + + return values[0], values[1], values[2], nil +} + +// parseVersionComponent parses one unsigned ASCII-decimal version component, +// rejecting signs, whitespace, non-digits, empty strings, and overflow. +func parseVersionComponent(s string) (int, bool) { + if s == "" { + return 0, false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, false + } + } + v, err := strconv.ParseUint(s, 10, 31) + if err != nil { + return 0, false + } + return int(v), true +} diff --git a/KC-Core/pkg/bssci/crypto.go b/KC-Core/pkg/bssci/crypto.go index 8c19ced..3eb8ac9 100644 --- a/KC-Core/pkg/bssci/crypto.go +++ b/KC-Core/pkg/bssci/crypto.go @@ -25,9 +25,9 @@ func ValidateAttachSignature(epEUI uint64, attachCnt uint32, signature []byte, p iv[9] = 0x00 maskedCnt := attachCnt & 0xFFFFFF - iv[10] = byte(maskedCnt >> 16) - iv[11] = byte(maskedCnt >> 8) - iv[12] = byte(maskedCnt) + iv[10] = byte(maskedCnt >> 16) //nolint:gosec // G115: intentional byte extraction of 24-bit counter + iv[11] = byte(maskedCnt >> 8) //nolint:gosec // G115: intentional byte extraction of 24-bit counter + iv[12] = byte(maskedCnt) //nolint:gosec // G115: intentional byte extraction of 24-bit counter iv[13] = 0xFF iv[14] = 0xFF diff --git a/KC-Core/pkg/bssci/detach_integration_test.go b/KC-Core/pkg/bssci/detach_integration_test.go index 053b53b..e32904a 100644 --- a/KC-Core/pkg/bssci/detach_integration_test.go +++ b/KC-Core/pkg/bssci/detach_integration_test.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "encoding/json" "fmt" + "strconv" "sync" "testing" "time" @@ -248,7 +249,7 @@ func buildDetachPayload(epEui uint64) map[string]interface{} { return map[string]interface{}{ "command": mioty.CmdDetach, "epEui": float64(epEui), - "rxTime": float64(time.Now().UnixNano()), + "rxTime": time.Now().UnixNano(), "packetCnt": float64(10), "snr": float64(12.5), "rssi": float64(-85.2), @@ -739,7 +740,8 @@ func TestDetachCrossTenantLookup(t *testing.T) { } // TestDetachPayloadNormalization verifies DET-03: handler correctly processes -// payload with mixed float64 types and signature arrays (from JSON unmarshaling). +// payload with json.Number values and signature arrays (the shapes produced by +// strict JSON decoding with UseNumber). func TestDetachPayloadNormalization(t *testing.T) { t.Parallel() @@ -754,19 +756,21 @@ func TestDetachPayloadNormalization(t *testing.T) { MessageEncoding: EncodingJSON, }, endpoint) - // DET-03: Payload with mixed float64 types (from JSON unmarshaling) + // DET-03: Payload with json.Number values (from strict JSON decoding); + // rxTime exceeds 2^53 and must survive exactly payload := map[string]interface{}{ "command": mioty.CmdDetach, - "epEui": float64(epEui), // Will be normalized to uint64 - "bsEui": float64(0xABCDEF123456), // Will be normalized to uint64 - "rxTime": float64(1699876543000000000), // Will be normalized to int64 - "packetCnt": float64(42), // Will be normalized to uint32 - "snr": float64(15.7), // Stays float64 - "rssi": float64(-92.3), // Stays float64 - "eqSnr": float64(14.2), // Stays float64 - "profile": "eu1", // Stays string - "rxDuration": float64(500), // Will be normalized to int64 - "sign": []interface{}{1.0, 2.0, 3.0, 4.0}, // Will be normalized to []byte + "epEui": json.Number(strconv.FormatUint(epEui, 10)), // Will be normalized to uint64 + "bsEui": json.Number("188900967593046"), // Will be normalized to uint64 + "rxTime": json.Number("1699876543000000000"), // Will be normalized to int64 + "packetCnt": json.Number("42"), // Will be normalized to uint32 + "snr": json.Number("15.7"), // Becomes float64 + "rssi": json.Number("-92.3"), // Becomes float64 + "eqSnr": json.Number("14.2"), // Becomes float64 + "profile": "eu1", // Stays string + "rxDuration": json.Number("500"), // Will be normalized to int64 + "sign": []interface{}{json.Number("1"), json.Number("2"), + json.Number("3"), json.Number("4")}, // Will be normalized to []byte } msg := &Message{Command: mioty.CmdDetach, OpId: opID, Data: payload} diff --git a/KC-Core/pkg/bssci/dl_rx_status_test.go b/KC-Core/pkg/bssci/dl_rx_status_test.go index 658d5f3..ea18d2d 100644 --- a/KC-Core/pkg/bssci/dl_rx_status_test.go +++ b/KC-Core/pkg/bssci/dl_rx_status_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + bssciservices "github.com/Kiloiot/kilo-service-center/KC-Core/internal/services/bssci" bssci "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" @@ -192,8 +193,10 @@ func TestHandlerRegistration(t *testing.T) { // Create real services for test logger := logger.NewNop() sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(logger, nil) + versionNegotiator, err := bssciservices.NewVersionNegotiator([]string{mioty.MIOTYProtocolVersion}, logger) + require.NoError(t, err, "NewVersionNegotiator should build from the canonical version") server, err := bssci.NewServer(&bssci.Config{}, logger, nil, nil, nil, nil, nil, 1, - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, nil, 1) + sessionSvc, versionNegotiator, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, nil, 1) require.NoError(t, err, "NewServer should not return error with valid StatusService") server.RegisterHandlers() diff --git a/KC-Core/pkg/bssci/downlink_handlers.go b/KC-Core/pkg/bssci/downlink_handlers.go index 859e76e..5a298c2 100644 --- a/KC-Core/pkg/bssci/downlink_handlers.go +++ b/KC-Core/pkg/bssci/downlink_handlers.go @@ -339,19 +339,22 @@ func (s *Server) SendDLDataQueue(sessionID string, epEui uint64, payloads [][]by } userDataField = userDataArray } else { - // Non-counter-dependent: userData is Numeric[m][n] with m=1 per BSSCI §5.12.1 - // ("single user data entry if cntDepend is false" — still wrapped in outer array - // to keep the declared Numeric[m][n] shape). Empty payloads form an ACK-only - // downlink with no entries. - if len(payloads) == 0 { - userDataField = []interface{}{} - } else { - singlePayload := make([]interface{}, len(payloads[0])) + // Non-counter-dependent: userData is Numeric[m][n] with m=1 per BSSCI §3.12.1 + // ("single user data entry if cntDepend is false"). For an ACK-only downlink + // ("If user data is empty, a pure acknowledgement downlink is queued") the + // single entry is zero bytes long — outer length must still be 1 or the + // Fraunhofer BS rejects the message with code=22 "DL data queue message + // malformed". + var singlePayload []interface{} + if len(payloads) > 0 { + singlePayload = make([]interface{}, len(payloads[0])) for i, b := range payloads[0] { singlePayload[i] = uint8(b) } - userDataField = []interface{}{singlePayload} + } else { + singlePayload = []interface{}{} } + userDataField = []interface{}{singlePayload} } // Create dlDataQue message per BSSCI §5.12.1 @@ -1596,7 +1599,11 @@ func (s *Server) QueueDownlink(req *mioty.DLDataQueue, tenantID int64) (queuedQu } } - // Call existing SendDLDataQueue to handle the BSSCI protocol details + // Call existing SendDLDataQueue to handle the BSSCI protocol details. + // The SCACI-only dlRxStatQry hint is not part of mioty.DLDataQueue and is + // honoured by the auto-dispatcher (which reads DlRxStatQry from the + // storage.DownlinkMessage row and pairs the §3.16 query with the queue + // operation), not here. err = s.SendDLDataQueue(sessionID, req.EpEui, payloads, queId, prio, req.CntDepend, packetCnt, format, responseExp, responsePrio, dlWindReq, expOnly, tenantID) @@ -1604,17 +1611,6 @@ func (s *Server) QueueDownlink(req *mioty.DLDataQueue, tenantID int64) (queuedQu return 0, 0, err } - // Auto-trigger DL RX status query if requested (BSSCI §3.16) - if req.DlRxStatQry != nil && *req.DlRxStatQry { - if qryErr := s.SendDLRXStatusQuery(sessionID, req.EpEui); qryErr != nil { - s.logger.Warn(ResolveErrorMessage(errFailedToSendDlRxStatQry), - "sessionID", sessionID, - "epEui", req.EpEui, - "error", qryErr) - // Downlink already queued — log and continue - } - } - // Return the queue ID and selected base station EUI return req.QueId, selectedBsEui, nil } diff --git a/KC-Core/pkg/bssci/errors_catalog.go b/KC-Core/pkg/bssci/errors_catalog.go index ede50c5..e3ccda9 100644 --- a/KC-Core/pkg/bssci/errors_catalog.go +++ b/KC-Core/pkg/bssci/errors_catalog.go @@ -63,6 +63,7 @@ const ( errFailedToLoadTLS = "bssci.error.failed_to_load_tls" errFailedToStartTLS = "bssci.error.failed_to_start_tls" errCommandBeforeHandshake = "bssci.error.command_before_handshake" + errInvalidHandshakeState = "bssci.error.invalid_handshake_state" errMissingProtocolVersion = "bssci.error.missing_protocol_version" errMissingBidiFlag = "bssci.error.missing_bidi_flag" @@ -301,7 +302,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.session_not_found", Message: "Session not found", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errHandshakeNotComplete: { Token: "bssci.error.handshake_not_complete", @@ -313,13 +314,13 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.session_nil", Message: "Invalid session: session is nil", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errInvalidSessionType: { Token: "bssci.error.invalid_session_type", Message: "Invalid session type or nil session", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errCannotSendPing: { Token: "bssci.error.cannot_send_ping", @@ -331,7 +332,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.base_station_not_registered", Message: "Base station not registered", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errResumeCounterMismatch: { Token: "bssci.error.resume_counter_mismatch", @@ -357,55 +358,55 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.failed_to_encode", Message: "Failed to encode message", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errFailedToMarshal: { Token: "bssci.error.failed_to_marshal", Message: "Failed to marshal message", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errFailedToMarshalMeta: { Token: "bssci.error.failed_to_marshal_metadata", Message: "Failed to marshal metadata", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errFailedToWrite: { Token: "bssci.error.failed_to_write", Message: "Failed to write", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errFailedToWriteHeader: { Token: "bssci.error.failed_to_write_header", Message: "Failed to write header", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errFailedToWritePayload: { Token: "bssci.error.failed_to_write_payload", Message: "Failed to write payload", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errFailedToDecode: { Token: "bssci.error.failed_to_decode", Message: "Failed to decode", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errFailedToDecodePayload: { Token: "bssci.error.failed_to_decode_payload", Message: "Failed to decode payload", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, errPayloadTooLarge: { Token: "bssci.error.payload_too_large", Message: "Payload exceeds maximum size (4GB for uint32 length field)", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, // Version negotiation errors (BSSCI §2.1-2.3) @@ -527,61 +528,61 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.missing_bs_eui", Message: "Missing bsEui", SpecSection: "§3.3.1", - Severity: "error", + Severity: SeverityError, }, errInvalidBsEui: { Token: "bssci.error.invalid_bs_eui", Message: "Invalid bsEui: negative value not allowed", SpecSection: "§3.3.1", - Severity: "error", + Severity: SeverityError, }, errInvalidUUIDByteType: { Token: "bssci.error.invalid_uuid_byte_type", Message: "Invalid UUID byte type", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errInvalidUUIDLength: { Token: "bssci.error.invalid_uuid_length", Message: "UUID must be 16 bytes", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errUnsupportedUUIDType: { Token: "bssci.error.unsupported_uuid_type", Message: "Unsupported UUID type", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errNoConnectedBaseStations: { Token: "bssci.error.no_connected_base_stations", Message: "No connected base stations available", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errFailedToLoadCA: { Token: "bssci.error.failed_to_load_ca", Message: "Failed to load CA certificate", SpecSection: "§1", - Severity: "error", + Severity: SeverityError, }, errFailedToParseCA: { Token: "bssci.error.failed_to_parse_ca", Message: "Failed to parse CA certificate", SpecSection: "§1", - Severity: "error", + Severity: SeverityError, }, errFailedToLoadTLS: { Token: "bssci.error.failed_to_load_tls", Message: "Failed to load TLS certificate", SpecSection: "§1", - Severity: "error", + Severity: SeverityError, }, errFailedToStartTLS: { Token: "bssci.error.failed_to_start_tls", Message: "Failed to start TLS listener", SpecSection: "§1", - Severity: "error", + Severity: SeverityError, }, errCommandBeforeHandshake: { Token: "bssci.error.command_before_handshake", @@ -589,17 +590,23 @@ var errorDefinitions = map[string]ErrorDefinition{ SpecSection: "§3.3", Severity: "protocol_violation", }, + errInvalidHandshakeState: { + Token: "bssci.error.invalid_handshake_state", + Message: "Connect operation message not valid in current handshake state", + SpecSection: "§3.3", + Severity: "protocol_violation", + }, errMissingProtocolVersion: { Token: "bssci.error.missing_protocol_version", Message: "Protocol version must be provided by Base Station", SpecSection: "§5.3", - Severity: "error", + Severity: SeverityError, }, errMissingBidiFlag: { Token: "bssci.error.missing_bidi_flag", Message: "Bidirectional capability flag (bidi) must be explicitly declared", SpecSection: "§5.3", - Severity: "error", + Severity: SeverityError, }, // Attach operation errors (BSSCI §3.6) @@ -607,55 +614,55 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.missing_ep_eui", Message: "Missing epEui", SpecSection: "§3.6.1", - Severity: "error", + Severity: SeverityError, }, errAttachOperationFailed: { Token: "bssci.error.attach_operation_failed", Message: "Attach operation failed", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, errAttachPropagateFailed: { Token: "bssci.error.attach_propagate_failed", Message: "Attach propagate failed", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, errPropagationBroadcastFailure: { Token: "bssci.error.propagation_broadcast_failure", Message: "Propagation broadcast encountered failures", SpecSection: "§5.8.3", - Severity: "error", + Severity: SeverityError, }, errEndpointAttachFailed: { Token: "bssci.error.endpoint_attach_failed", Message: "Endpoint attach failed", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, errInvalidEndpointEUIFormat: { Token: "bssci.error.invalid_endpoint_eui_format", Message: "Invalid endpoint EUI format", SpecSection: "§3.6.1", - Severity: "error", + Severity: SeverityError, }, errBaseStationNotBidirectional: { Token: "bssci.error.base_station_not_bidirectional", Message: "Base station not bidirectional", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, errNoPendingAttachOperation: { Token: "bssci.error.no_pending_attach_operation", Message: "No pending attach operation", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, errInvalidNwkSnKeyLength: { Token: "bssci.error.invalid_nwk_sn_key_length", Message: "Network session key must be exactly 16 bytes", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, errBaseStationEUIOutOfRange: { Token: "bssci.error.base_station_eui_out_of_range", @@ -669,55 +676,55 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.detach_operation_failed", Message: "Detach operation failed", SpecSection: "§3.7", - Severity: "error", + Severity: SeverityError, }, errDetachPropagateFailed: { Token: "bssci.error.detach_propagate_failed", Message: "Detach propagate failed", SpecSection: "§3.7", - Severity: "error", + Severity: SeverityError, }, errAttachPropagateRejected: { Token: "bssci.error.attach_propagate_rejected", Message: "Base station rejected attach propagate operation", SpecSection: "§5.17", - Severity: "error", + Severity: SeverityError, }, errDetachPropagateRejected: { Token: "bssci.error.detach_propagate_rejected", Message: "Base station rejected detach propagate operation", SpecSection: "§5.17", - Severity: "error", + Severity: SeverityError, }, errEndpointDetachFailed: { Token: "bssci.error.endpoint_detach_failed", Message: "Endpoint detach failed", SpecSection: "§3.7", - Severity: "error", + Severity: SeverityError, }, errNoPendingDetachOperation: { Token: "bssci.error.no_pending_detach_operation", Message: "No pending detach operation", SpecSection: "§3.7", - Severity: "error", + Severity: SeverityError, }, errDetachSignatureInvalid: { Token: "bssci.error.detach_signature_invalid", Message: "Unknown endpoint detach signature validation failed", SpecSection: "§5.7", - Severity: "error", + Severity: SeverityError, }, errEndpointNotFound: { Token: "bssci.error.endpoint_not_found", Message: "Unknown endpoint not found in database", SpecSection: "§5.7", - Severity: "error", + Severity: SeverityError, }, errSignatureMismatch: { Token: "bssci.error.signature_mismatch", Message: "Cryptographic signature verification failed", SpecSection: "§5.7.1", - Severity: "error", + Severity: SeverityError, }, // Downlink adapter errors (BSSCI §5.13) @@ -725,7 +732,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.dl_queue_nil_result", Message: "QueueDownlinkInternal returned nil result", SpecSection: "§5.13", - Severity: "error", + Severity: SeverityError, }, // Downlink payload size (MIOTY radio protocol §4.3.2) @@ -733,7 +740,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.dl_payload_too_large", Message: "Downlink payload exceeds 200-byte maximum (MIOTY radio protocol §4.3.2)", SpecSection: "§4.3.2", - Severity: "error", + Severity: SeverityError, }, // Downlink data queue errors (BSSCI §3.9) @@ -741,49 +748,49 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.invalid_queue_id", Message: "Invalid queue ID", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errQueueIDNotFound: { Token: "bssci.error.queue_id_not_found", Message: "Queue ID not found", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errMissingQueID: { Token: "bssci.error.missing_que_id", Message: "Missing queId", SpecSection: "§3.9.1", - Severity: "error", + Severity: SeverityError, }, errCannotResolveTenantForQueue: { Token: "bssci.error.cannot_resolve_tenant_for_queue", Message: "Cannot resolve tenant for queue", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errInvalidPayloadCount: { Token: "bssci.error.invalid_payload_count", Message: "Invalid payload count", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errCounterDependentPayloadMismatch: { Token: "bssci.error.counter_dependent_payload_mismatch", Message: "Counter-dependent mode payload mismatch", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errNonCounterDependentMultiPayload: { Token: "bssci.error.non_counter_dependent_multi_payload", Message: "Non-counter-dependent mode requires exactly 1 payload", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errMissingPacketCnt: { Token: "bssci.error.missing_packet_cnt", Message: "Missing packetCnt", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errMissingResultField: { Token: "bssci.error.missing_result_field", @@ -807,19 +814,19 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.invalid_tenant_id_format", Message: "Invalid tenant ID format", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errQueueIDOutOfRange: { Token: "bssci.error.queue_id_out_of_range", Message: "Queue ID exceeds maximum safe value for int64 conversion", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errNegativeQueueIDInMetadata: { Token: "bssci.error.negative_queue_id_in_metadata", Message: "Negative queue ID found in operation metadata", SpecSection: "§3.9", - Severity: "error", + Severity: SeverityError, }, errInvalidPacketCounter: { Token: "bssci.error.invalid_packet_counter", @@ -839,19 +846,19 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.failed_to_send_dl_data_rev", Message: "Failed to send dlDataRev", SpecSection: "§3.10", - Severity: "error", + Severity: SeverityError, }, errMissingEpEuiInMetadata: { Token: "bssci.error.missing_ep_eui_in_metadata", Message: "Missing epEui in metadata", SpecSection: "§3.10", - Severity: "error", + Severity: SeverityError, }, errMissingQueIDInMetadata: { Token: "bssci.error.missing_que_id_in_metadata", Message: "Missing queId in metadata", SpecSection: "§3.10", - Severity: "error", + Severity: SeverityError, }, // DL RX Status errors (BSSCI §3.11) @@ -859,19 +866,19 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.failed_to_persist_dl_rx_status", Message: "Failed to persist DL RX status", SpecSection: "§3.11", - Severity: "error", + Severity: SeverityError, }, errFailedToSendDlRxStatQry: { Token: "bssci.error.failed_to_send_dl_rx_stat_qry", Message: "Failed to send dlRxStatQry", SpecSection: "§3.11", - Severity: "error", + Severity: SeverityError, }, errInvalidResultValue: { Token: "bssci.error.invalid_result_value", Message: "Invalid result value", SpecSection: "§3.11", - Severity: "error", + Severity: SeverityError, }, errMissingDlRxEpEui: { Token: "bssci.error.missing_dl_rx_ep_eui", @@ -933,31 +940,31 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.database_error", Message: "Database error", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, }, errDatabaseNotAvailable: { Token: "bssci.error.database_not_available", Message: "Database not available", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, }, errDatabaseUpdateFailed: { Token: "bssci.error.database_update_failed", Message: "Database update failed", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, }, errDeduplicationError: { Token: "bssci.error.deduplication_error", Message: "Deduplication error", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errPacketCounterCollision: { Token: "bssci.error.packet_counter_collision", Message: "Packet counter collision detected during deduplication", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, // Version and protocol compatibility errors @@ -971,7 +978,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.uuid_data_nil", Message: "UUID data is nil", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, errSCOperationIDMustNotIncrease: { Token: "bssci.error.sc_operation_id_must_not_increase", @@ -985,31 +992,31 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.operation_failed", Message: "Operation failed", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errPropagateFailed: { Token: "bssci.error.propagate_failed", Message: "Propagate failed", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errEndpointOperationFailed: { Token: "bssci.error.endpoint_operation_failed", Message: "Endpoint operation failed", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, errFailedToSendError: { Token: "bssci.error.failed_to_send_error", Message: "Failed to send error", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, }, errBaseStationReportedError: { Token: "bssci.error.base_station_reported_error", Message: "Base station reported error", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, }, // Variable MAC (VM) operation errors @@ -1023,37 +1030,37 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.missing_mac_type", Message: "Missing macType in VM uplink data", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errMissingRxTime: { Token: "bssci.error.missing_rx_time", Message: "Missing rxTime in VM uplink data", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errFailedToSendVMActivate: { Token: "bssci.error.failed_to_send_vm_activate", Message: "Failed to send VM activate", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errFailedToSendVMDeactivate: { Token: "bssci.error.failed_to_send_vm_deactivate", Message: "Failed to send VM deactivate", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errFailedToSendVMStatus: { Token: "bssci.error.failed_to_send_vm_status", Message: "Failed to send VM status", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, errFailedToSendVMDlData: { Token: "bssci.error.failed_to_send_vm_dl_data", Message: "Failed to send VM downlink data", SpecSection: "§3", - Severity: "error", + Severity: SeverityError, }, // Uplink transmit errors @@ -1061,61 +1068,61 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.nwk_sn_key_invalid_length", Message: "nwkSnKey must be exactly 16 bytes", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errBaseStationTenantMismatch: { Token: "bssci.error.base_station_tenant_mismatch", Message: "Base station belongs to different tenant - cannot schedule UL transmit", SpecSection: "§5.11 (UL data transmit), multi-tenant isolation", - Severity: "error", + Severity: SeverityError, }, errSessionNotReady: { Token: "bssci.error.session_not_ready", Message: "Base station session not ready - handshake incomplete", SpecSection: "§3.3 (connect), §5.16 (DL RX status query)", - Severity: "error", + Severity: SeverityError, }, errSessionNotBidirectional: { Token: "bssci.error.session_not_bidirectional", Message: "Base station does not support bidirectional operations", SpecSection: "§5.16 (DL RX status query), bidirectional capability check", - Severity: "error", + Severity: SeverityError, }, errFailedToSendULDataTransmitComplete: { Token: "bssci.error.failed_to_send_ul_data_transmit_complete", Message: "Failed to send UL data transmit completion", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errFailedToEncryptKey: { Token: "bssci.error.failed_to_encrypt_key", Message: "Failed to encrypt key", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errFailedToDecryptKey: { Token: "bssci.error.failed_to_decrypt_key", Message: "Failed to decrypt key", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errMissingEncryptedKey: { Token: "bssci.error.missing_encrypted_key", Message: "Encrypted key missing from metadata", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errFailedToDecodeUserData: { Token: "bssci.error.failed_to_decode_user_data", Message: "Failed to decode userData", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errFailedToSendULTransmit: { Token: "bssci.error.failed_to_send_ul_transmit", Message: "Failed to send UL transmit to BS", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errMissingAttachCnt: { Token: "bssci.error.missing_attach_cnt", @@ -1145,19 +1152,19 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.invalid_attach_cnt_range", Message: "attachCnt must be 24-bit unsigned (0 to 16777215)", SpecSection: "§5.6.1", - Severity: "error", + Severity: SeverityError, }, errInvalidNonceElement: { Token: "bssci.error.invalid_nonce_element", Message: "nonce array elements must be integers 0-255", SpecSection: "§5.6.1", - Severity: "error", + Severity: SeverityError, }, errInvalidSignElement: { Token: "bssci.error.invalid_sign_element", Message: "sign array elements must be integers 0-255", SpecSection: "§5.6.1", - Severity: "error", + Severity: SeverityError, }, errAttachCounterNotMonotonic: { Token: "bssci.error.attach_counter_not_monotonic", @@ -1187,13 +1194,13 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.endpoint_not_provisioned", Message: "Endpoint not provisioned", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errRoamingNotAllowed: { Token: "bssci.error.roaming_not_allowed", Message: "Roaming not allowed between tenants", SpecSection: "§3.8 (roaming)", - Severity: "error", + Severity: SeverityError, }, errResponseExpRequiresDlOpen: { Token: "bssci.error.response_exp_requires_dl_open", @@ -1223,13 +1230,13 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.failed_to_persist_ul_data", Message: "Failed to persist UL data", SpecSection: "§3.8", - Severity: "error", + Severity: SeverityError, }, errFailedToResolveEndpointTenant: { Token: "bssci.error.failed_to_resolve_endpoint_tenant", Message: "Failed to resolve endpoint tenant for UL data - database error", SpecSection: "§3.8.1", - Severity: "error", + Severity: SeverityError, }, errUnsupportedSublayerPrefix: { Token: "bssci.error.unsupported_sublayer_prefix", @@ -1249,7 +1256,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.failed_to_send_status_request", Message: "Failed to send status request", SpecSection: "§3.5", - Severity: "error", + Severity: SeverityError, }, errMissingStatusCode: { Token: "bssci.error.missing_status_code", @@ -1275,25 +1282,25 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.mgmt.method_not_allowed", Message: "Method not allowed", SpecSection: "§3.6,§3.7", // Attach/detach propagation endpoints - Severity: "error", + Severity: SeverityError, }, ErrMgmtSessionIDRequired: { Token: "bssci.mgmt.session_id_required", Message: "session_id query parameter required", SpecSection: "§3.3", // Session management - Severity: "error", + Severity: SeverityError, }, ErrMgmtInvalidRequestBody: { Token: "bssci.mgmt.invalid_request_body", Message: "Invalid request body", SpecSection: "§3.6,§3.7", - Severity: "error", + Severity: SeverityError, }, ErrMgmtInvalidNwkSnKeyEncoding: { Token: "bssci.mgmt.invalid_nwk_sn_key_encoding", Message: "Invalid nwkSnKey encoding", SpecSection: "§3.8.1", // Network session key requirements - Severity: "error", + Severity: SeverityError, }, ErrMgmtInvalidNwkSnKeyLength: { Token: "bssci.mgmt.invalid_nwk_sn_key_length", @@ -1305,49 +1312,49 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.mgmt.attach_propagate_failed", Message: "Failed to send attach propagate", SpecSection: "§3.6", - Severity: "error", + Severity: SeverityError, }, ErrMgmtDetachPropagateFailed: { Token: "bssci.mgmt.detach_propagate_failed", Message: "Failed to send detach propagate", SpecSection: "§3.7", - Severity: "error", + Severity: SeverityError, }, ErrMgmtJSONEncodeFailed: { Token: "bssci.mgmt.json_encode_failed", Message: "Failed to encode JSON response", SpecSection: "§3.1", // Message encoding - Severity: "error", + Severity: SeverityError, }, ErrMgmtFailedToBindAddress: { Token: "bssci.mgmt.failed_to_bind_address", Message: "Failed to bind to management API address", SpecSection: "N/A", // Infrastructure, not protocol - Severity: "error", + Severity: SeverityError, }, ErrMgmtServerFailed: { Token: "bssci.mgmt.server_failed", Message: "Management HTTP server failed", SpecSection: "N/A", - Severity: "error", + Severity: SeverityError, }, ErrMgmtInvalidTenantContext: { Token: "bssci.mgmt.invalid_tenant_context", Message: "Cannot resolve tenant context for operation", SpecSection: "§3.2", // Operation context - Severity: "error", + Severity: SeverityError, }, ErrMgmtNoConnectedSessions: { Token: "bssci.mgmt.no_connected_sessions", Message: "No connected base station sessions available", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, ErrMgmtSessionLookupFailed: { Token: "bssci.mgmt.session_lookup_failed", Message: "Failed to look up session state", SpecSection: "§3.3", - Severity: "error", + Severity: SeverityError, }, // Type conversion and validation errors @@ -1363,7 +1370,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.org_context_missing", Message: "Organization context required in strict mode (gRPC metadata missing X-Organization-ID)", SpecSection: "§3.1", - Severity: "error", + Severity: SeverityError, }, // Float validation errors (BSSCI §5.15.1) @@ -1435,7 +1442,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.unknown", Message: "Unknown error", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, }, } @@ -1450,7 +1457,7 @@ func GetErrorDefinition(token string) ErrorDefinition { Token: token, Message: "Unknown error", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, } } diff --git a/KC-Core/pkg/bssci/log_messages.go b/KC-Core/pkg/bssci/log_messages.go index fbd7c2a..09dd867 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -553,6 +553,8 @@ const ( LogBSSCIInvalidServerProtocolVersion = "Invalid server protocol version" // LogBSSCIMinorVersionMismatch is a log message constant LogBSSCIMinorVersionMismatch = "Minor version mismatch" + // LogBSSCIMinorVersionNegotiatedDown is logged when a base station requests a newer minor version and the session continues at the service center's selected version (BSSCI §5.3.2) + LogBSSCIMinorVersionNegotiatedDown = "Minor version newer than service center, negotiating down" // LogBSSCIBaseStationConnected is a log message constant LogBSSCIBaseStationConnected = "Base Station connected" // LogBSSCIBaseStationFoundInDatabase is a log message constant @@ -880,6 +882,8 @@ const ( LogBSSCIResolvedRoamingEndpointTenant = "Resolved roaming endpoint tenant via database lookup" // LogBSSCIEUIPrecisionLoss is logged when EUI value exceeds float64 safe integer range (>2^53) during type conversion LogBSSCIEUIPrecisionLoss = "EUI precision loss - value exceeds float64 safe integer range" + // LogBSSCINumericPrecisionLoss is logged when a non-EUI numeric field value exceeds the exact integer range of its wire float representation + LogBSSCINumericPrecisionLoss = "Numeric precision loss - value exceeds exact float integer range" // ======================================================================== // Propagation Reconciliation (17 constants) diff --git a/KC-Core/pkg/bssci/mandatory_field_validation_test.go b/KC-Core/pkg/bssci/mandatory_field_validation_test.go index ee7c921..bad8e6a 100644 --- a/KC-Core/pkg/bssci/mandatory_field_validation_test.go +++ b/KC-Core/pkg/bssci/mandatory_field_validation_test.go @@ -94,7 +94,7 @@ func buildValidAttachData(overrideField string, overrideValue interface{}) map[s data := map[string]interface{}{ "epEui": float64(TestEpEui01), - "rxTime": float64(time.Now().UnixNano()), + "rxTime": time.Now().UnixNano(), "snr": float64(10.5), "rssi": float64(-85.0), "attachCnt": attachCnt, @@ -119,7 +119,7 @@ func buildAttachDataWithCnt(attachCnt int64) map[string]interface{} { return map[string]interface{}{ "epEui": float64(TestEpEui01), - "rxTime": float64(time.Now().UnixNano()), + "rxTime": time.Now().UnixNano(), "snr": float64(10.5), "rssi": float64(-85.0), "attachCnt": attachCnt, @@ -1191,9 +1191,9 @@ func TestDetachMandatoryFields(t *testing.T) { code, hasCode := errorMsg["code"] assert.True(t, hasCode, "Error must include code") - codeVal, ok := code.(float64) - require.True(t, ok, "code must be float64, got %T", code) - assert.Equal(t, float64(tt.expectedErrorCode), codeVal, + codeVal, err := coerceInt64(code) + require.NoError(t, err, "code must be numeric, got %T", code) + assert.Equal(t, int64(tt.expectedErrorCode), codeVal, "Missing %s must return POSIX 71 (EPROTO) per BSSCI §4", tt.missingField) }) } diff --git a/KC-Core/pkg/bssci/message_assembly_test.go b/KC-Core/pkg/bssci/message_assembly_test.go index f29f54b..65d85c6 100644 --- a/KC-Core/pkg/bssci/message_assembly_test.go +++ b/KC-Core/pkg/bssci/message_assembly_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net" "testing" "time" @@ -379,6 +380,11 @@ func TestComplexSCOperations(t *testing.T) { {"SendDetachPropagate", testSendDetachPropagate}, {"SendDLDataQueue", testSendDLDataQueue}, {"SendDLDataQueue_CounterDependent", testSendDLDataQueueCounterDependent}, + {"SendDLDataQueue_ACKOnly", testSendDLDataQueueACKOnly}, + {"SendDLDataQueue_OptionalFlagsTrue", testSendDLDataQueueOptionalFlagsTrue}, + {"SendDLDataQueue_OptionalFlagsFalse", testSendDLDataQueueOptionalFlagsFalse}, + {"SendDLDataQueue_FormatBoundary", testSendDLDataQueueFormatBoundary}, + {"SendDLDataQueue_PrioAlwaysPresent", testSendDLDataQueuePrioAlwaysPresent}, {"SendDLDataRevoke", testSendDLDataRevoke}, {"SendDLRXStatusQuery", testSendDLRXStatusQuery}, {"SendULDataTransmit", testSendULDataTransmit}, @@ -585,6 +591,154 @@ func testSendDLDataQueueCounterDependent(t *testing.T, encoding string) { t.Logf("SendDLDataQueue counter-dependent (%s) emitted fields: %v", encoding, getFieldNames(msg)) } +// testSendDLDataQueueACKOnly pins the BSSCI §3.12.1 ACK-only wire shape +// ("If user data is empty, a pure acknowledgement downlink is queued"). +// Even with no payload, the outer slice MUST have one zero-length inner +// entry — sending an empty outer ([]) makes the AVA base station reject +// the message with BSSCI error 22 "DL data queue message malformed". +func testSendDLDataQueueACKOnly(t *testing.T, encoding string) { + server, mockConn, session := newAssemblySession(t, encoding, "test-session") + + err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, + [][]byte{}, 1001, 0, false, nil, 0, + false, false, false, false, 1) + require.NoError(t, err, "ACK-only SendDLDataQueue should succeed") + require.Len(t, mockConn.sentMessages, 1) + msg := mockConn.sentMessages[0] + + outer, ok := msg["userData"].([]interface{}) + require.Truef(t, ok, "userData must be an outer slice (got %T)", msg["userData"]) + require.Lenf(t, outer, 1, + "ACK-only userData outer length must be 1 (got %d) — empty outer triggers BS error 22", + len(outer)) + switch inner := outer[0].(type) { + case []byte: + require.Empty(t, inner, "ACK-only inner payload must be zero bytes") + case []interface{}: + require.Empty(t, inner, "ACK-only inner payload must be zero entries") + default: + t.Fatalf("ACK-only inner entry has unexpected type %T", outer[0]) + } + assert.Equal(t, false, msg["cntDepend"], "ACK-only path must use cntDepend=false") + assert.NotContains(t, msg, "packetCnt", "ACK-only path must omit packetCnt") +} + +// testSendDLDataQueueOptionalFlagsTrue locks the wire-presence guarantee for +// every optional bool field defined in BSSCI §3.12.1 when set true, so +// BS-side behaviour claims have factual grounding about what was sent. +func testSendDLDataQueueOptionalFlagsTrue(t *testing.T, encoding string) { + server, mockConn, session := newAssemblySession(t, encoding, "test-session") + + err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, + [][]byte{{0x02}}, 1002, 0.5, false, nil, 0, + true, true, true, true, 1) + require.NoError(t, err) + require.Len(t, mockConn.sentMessages, 1) + msg := mockConn.sentMessages[0] + + for _, field := range []string{"responseExp", "responsePrio", "dlWindReq", "expOnly"} { + val, present := msg[field] + require.Truef(t, present, "field %q must be present on the wire when set true", field) + assert.Equalf(t, true, val, "field %q must encode to true", field) + } +} + +// testSendDLDataQueueOptionalFlagsFalse asserts the same optional bool fields +// are OMITTED when false, keeping the wire compact and pinning the shape the +// BS sees against refactors that would always emit the flags. +func testSendDLDataQueueOptionalFlagsFalse(t *testing.T, encoding string) { + server, mockConn, session := newAssemblySession(t, encoding, "test-session") + + err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, + [][]byte{{0x02}}, 1003, 0.5, false, nil, 0, + false, false, false, false, 1) + require.NoError(t, err) + require.Len(t, mockConn.sentMessages, 1) + msg := mockConn.sentMessages[0] + + for _, field := range []string{"responseExp", "responsePrio", "dlWindReq", "expOnly"} { + _, present := msg[field] + assert.Falsef(t, present, "field %q must be absent on the wire when false", field) + } +} + +// testSendDLDataQueueFormatBoundary checks both extremes of the BSSCI §3.12.1 +// format identifier: 0 (default → omitted) and 255 (max u8 → present with +// full value). +func testSendDLDataQueueFormatBoundary(t *testing.T, encoding string) { + cases := []struct { + name string + format uint8 + wantPres bool + }{ + {"format=0 omitted", 0, false}, + {"format=255 present", 255, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server, mockConn, session := newAssemblySession(t, encoding, "test-session") + + err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, + [][]byte{{0x02}}, 1004, 0.5, false, nil, tc.format, + false, false, false, false, 1) + require.NoError(t, err) + require.Len(t, mockConn.sentMessages, 1) + msg := mockConn.sentMessages[0] + + val, present := msg["format"] + assert.Equal(t, tc.wantPres, present, "format presence mismatch") + if tc.wantPres { + // Format can land as uint8/int64/float64 depending on encoder + var got int64 + switch v := val.(type) { + case uint8: + got = int64(v) + case int64: + got = v + case float64: + got = int64(v) + case int: + got = int64(v) + default: + t.Fatalf("format has unexpected type %T", val) + } + assert.Equal(t, int64(255), got, "format value mismatch") + } + }) + } +} + +// testSendDLDataQueuePrioAlwaysPresent verifies prio is always emitted +// regardless of value, so the BS-side ordering decision has the value it +// expects for both 0.0 (low) and 1.0 (high). +func testSendDLDataQueuePrioAlwaysPresent(t *testing.T, encoding string) { + for _, prio := range []float32{0.0, 1.0} { + t.Run(fmt.Sprintf("prio=%.1f", prio), func(t *testing.T) { + server, mockConn, session := newAssemblySession(t, encoding, "test-session") + + err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, + [][]byte{{0x02}}, 1005, prio, false, nil, 0, + false, false, false, false, 1) + require.NoError(t, err) + require.Len(t, mockConn.sentMessages, 1) + msg := mockConn.sentMessages[0] + + val, present := msg["prio"] + require.True(t, present, "prio must always be present on the wire") + var got float64 + switch v := val.(type) { + case float32: + got = float64(v) + case float64: + got = v + default: + t.Fatalf("prio has unexpected type %T", val) + } + assert.InDelta(t, float64(prio), got, 0.0001, "prio value mismatch") + }) + } +} + func testSendDLDataRevoke(t *testing.T, encoding string) { server, mockConn, session := newAssemblySession(t, encoding, "test-session") diff --git a/KC-Core/pkg/bssci/message_metadata.go b/KC-Core/pkg/bssci/message_metadata.go index 80cc91d..d87896a 100644 --- a/KC-Core/pkg/bssci/message_metadata.go +++ b/KC-Core/pkg/bssci/message_metadata.go @@ -78,6 +78,7 @@ type FieldSpec struct { SpecRef string // BSSCI spec reference (e.g., "§5.8.1 line 234") ByteLength int // For byte arrays: expected length (0 = variable) Validator func(interface{}) bool // Custom validation (optional) + EUI bool // Field carries an EUI-64 (full unsigned 64-bit range) } // ConditionalRule defines context-dependent field requirements @@ -197,6 +198,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.6.1 - End Point EUI64 (8 bytes)", @@ -319,6 +321,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.7.1 - End Point EUI64 (8 bytes)", @@ -400,6 +403,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.14.1 - End Point EUI64 (8 bytes)", @@ -442,6 +446,7 @@ var messageRegistry = map[string]*MessageSpec{ }, { Name: "bsEui", + EUI: true, Type: TypeUint64, Required: false, DefaultValue: nil, // Pointer type: nil when absent @@ -482,6 +487,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.11.1 - End Point EUI64 (8 bytes)", @@ -610,6 +616,7 @@ var messageRegistry = map[string]*MessageSpec{ }, { Name: "bsEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§3.1.1 - Base Station EUI64 (8 bytes)", @@ -802,6 +809,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.9.1 - End Point EUI64 (8 bytes)", @@ -882,6 +890,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.10.1 - End Point EUI64 (8 bytes)", @@ -939,6 +948,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.11.1 - End Point EUI64 (8 bytes)", @@ -1014,6 +1024,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.12.1 - End Point EUI64 (8 bytes)", @@ -1113,6 +1124,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.13.1 - End Point EUI64 (8 bytes)", @@ -1162,6 +1174,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.15.1 - End Point EUI64 (8 bytes)", @@ -1209,6 +1222,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.16.1 - End Point EUI64 (8 bytes)", @@ -1306,6 +1320,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.17.1 - End Point EUI64 (8 bytes)", @@ -1343,6 +1358,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.18.1 - End Point EUI64 (8 bytes)", @@ -1380,6 +1396,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.19.1 - End Point EUI64 (8 bytes)", @@ -1411,6 +1428,7 @@ var messageRegistry = map[string]*MessageSpec{ MandatoryFields: []FieldSpec{ { Name: "epEui", + EUI: true, Type: TypeUint64, Required: true, SpecRef: "§5.20.1 - End Point EUI64 (8 bytes)", diff --git a/KC-Core/pkg/bssci/message_normalizer.go b/KC-Core/pkg/bssci/message_normalizer.go index 12084d3..855b45d 100644 --- a/KC-Core/pkg/bssci/message_normalizer.go +++ b/KC-Core/pkg/bssci/message_normalizer.go @@ -13,9 +13,11 @@ package bssci import ( "context" + "encoding/json" "errors" "fmt" "math" + "math/big" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" ) @@ -38,8 +40,19 @@ var ( // ErrResponseExpRequiresDlOpen indicates responseExp=true requires dlOpen=true per BSSCI §5.10.1 ErrResponseExpRequiresDlOpen = errors.New(errResponseExpRequiresDlOpen) + + // errNumericPrecision marks conversions rejected because a wire float value's + // magnitude exceeds the exact-integer range of its IEEE 754 representation, + // so the original integer cannot be recovered without loss + errNumericPrecision = errors.New("numeric value beyond exact float integer range") ) +// precisionError wraps errNumericPrecision with the offending value for +// errors.Is() detection at the normalization boundary. +func precisionError(value interface{}) error { + return fmt.Errorf("%w: %v (%T)", errNumericPrecision, value, value) +} + // ============================================================================ // Normalization Function // ============================================================================ @@ -114,6 +127,7 @@ func normalizePayload(ctx context.Context, log logger.Logger, command string, da if err != nil { // Invalid field type - fail normalization // Sentinel error enables caller to use errors.Is() for token mapping + logNumericPrecisionLoss(ctx, log, command, fieldSpec, err) return nil, fmt.Errorf("%w for field %s: %v (spec: %s)", ErrInvalidFieldType, fieldSpec.Name, err, fieldSpec.SpecRef) } @@ -136,6 +150,7 @@ func normalizePayload(ctx context.Context, log logger.Logger, command string, da coerced, err := coerceFieldType(value, fieldSpec) if err != nil { // Sentinel error enables caller to use errors.Is() for token mapping + logNumericPrecisionLoss(ctx, log, command, fieldSpec, err) return nil, fmt.Errorf("%w for optional field %s: %v (spec: %s)", ErrInvalidFieldType, fieldSpec.Name, err, fieldSpec.SpecRef) } @@ -307,8 +322,104 @@ func coerceFieldType(value interface{}, fieldSpec FieldSpec) (interface{}, error } } -// coerceInt64 converts various numeric types to int64 -// Mirrors server.go:getNumericField behavior +// logNumericPrecisionLoss reports precision-rejected conversions at the +// normalization boundary, where command and field context are available. +// EUI fields use the dedicated EUI precision log; other numeric fields use +// the generic normalization log. +func logNumericPrecisionLoss(ctx context.Context, log logger.Logger, command string, fieldSpec FieldSpec, err error) { + if !errors.Is(err, errNumericPrecision) { + return + } + msg := LogBSSCINumericPrecisionLoss + if fieldSpec.EUI { + msg = LogBSSCIEUIPrecisionLoss + } + log.WarnContext(ctx, msg, + "command", command, + "field", fieldSpec.Name, + "error", err.Error(), + ) +} + +// checkExactIntegerFloat rejects NaN, infinities, fractional values, and +// magnitudes beyond the exact-integer range of the source float width +// (2^53 for float64, 2^24 for float32). +func checkExactIntegerFloat(v float64, bound uint64) error { + if math.IsNaN(v) || math.IsInf(v, 0) { + return fmt.Errorf("non-finite value %v cannot be coerced to integer", v) + } + if v != math.Trunc(v) { + return fmt.Errorf("fractional value %v cannot be coerced to integer", v) + } + if math.Abs(v) > float64(bound) { + return precisionError(v) + } + return nil +} + +// parseJSONNumber parses a json.Number into an exact rational value. +// big.Rat.SetString also accepts fractions ("1/2") and non-decimal forms that +// are not legal JSON numbers, so the token is validated as a JSON number first +// (wire input has already passed the decoder; manually constructed json.Number +// values have not). +func parseJSONNumber(n json.Number) (*big.Rat, error) { + s := string(n) + if s == "" || !json.Valid([]byte(s)) { + return nil, fmt.Errorf("invalid JSON number %q", s) + } + if c := s[0]; c != '-' && (c < '0' || c > '9') { + return nil, fmt.Errorf("invalid JSON number %q", s) + } + r, ok := new(big.Rat).SetString(s) + if !ok { + return nil, fmt.Errorf("invalid JSON number %q", s) + } + return r, nil +} + +// jsonNumberToUint64 converts a json.Number to uint64 exactly. Integral JSON +// forms such as "1", "1.0", and "1e3" are accepted; fractional, negative, and +// out-of-range values are rejected. Values up to math.MaxUint64 survive +// without any float conversion. +func jsonNumberToUint64(n json.Number) (uint64, error) { + r, err := parseJSONNumber(n) + if err != nil { + return 0, err + } + if !r.IsInt() { + return 0, fmt.Errorf("fractional value %s cannot be coerced to uint64", n) + } + i := r.Num() + if i.Sign() < 0 { + return 0, fmt.Errorf("negative value cannot be coerced to uint64: %s", n) + } + if !i.IsUint64() { + return 0, fmt.Errorf("value %s overflows uint64", n) + } + return i.Uint64(), nil +} + +// jsonNumberToInt64 converts a json.Number to int64 exactly, accepting +// integral JSON forms and rejecting fractional or out-of-range values. +func jsonNumberToInt64(n json.Number) (int64, error) { + r, err := parseJSONNumber(n) + if err != nil { + return 0, err + } + if !r.IsInt() { + return 0, fmt.Errorf("fractional value %s cannot be coerced to int64", n) + } + i := r.Num() + if !i.IsInt64() { + return 0, fmt.Errorf("value %s overflows int64", n) + } + return i.Int64(), nil +} + +// coerceInt64 converts wire numeric values to int64 with exact semantics: +// unsigned overflow, non-integral floats, and float magnitudes beyond the +// exact-integer range are rejected. Negative values are permitted (Service +// Center operation IDs are negative per BSSCI §3.2). func coerceInt64(value interface{}) (int64, error) { switch v := value.(type) { case int64: @@ -322,7 +433,14 @@ func coerceInt64(value interface{}) (int64, error) { case int8: return int64(v), nil case uint64: - //nolint:gosec // G115: Protocol EUIs/opIds are < 2^48, safe to cast to int64 + if v > math.MaxInt64 { + return 0, fmt.Errorf("value %d overflows int64", v) + } + return int64(v), nil + case uint: + if uint64(v) > math.MaxInt64 { + return 0, fmt.Errorf("value %d overflows int64", v) + } return int64(v), nil case uint32: return int64(v), nil @@ -331,28 +449,38 @@ func coerceInt64(value interface{}) (int64, error) { case uint8: return int64(v), nil case float64: - // JSON numbers come as float64 + if err := checkExactIntegerFloat(v, maxExactFloat64Integer); err != nil { + return 0, err + } return int64(v), nil case float32: - return int64(v), nil + f := float64(v) + if err := checkExactIntegerFloat(f, maxExactFloat32Integer); err != nil { + return 0, err + } + return int64(f), nil + case json.Number: + return jsonNumberToInt64(v) default: return 0, fmt.Errorf("cannot convert %T to int64", value) } } -// coerceUint64 converts various numeric types to uint64 +// coerceUint64 converts wire numeric values to uint64 with exact semantics. +// Unsigned integer widths are preserved exactly (the full EUI-64 range, +// including values above INT64_MAX, survives); negative values, non-integral +// floats, and float magnitudes beyond the exact-integer range are rejected. func coerceUint64(value interface{}) (uint64, error) { switch v := value.(type) { case uint64: return v, nil + case uint: + return uint64(v), nil case uint32: - // uint32 always fits in uint64 return uint64(v), nil case uint16: - // uint16 always fits in uint64 return uint64(v), nil case uint8: - // uint8 always fits in uint64 return uint64(v), nil case int64: if v < 0 { @@ -380,10 +508,24 @@ func coerceUint64(value interface{}) (uint64, error) { } return uint64(v), nil case float64: + if err := checkExactIntegerFloat(v, maxExactFloat64Integer); err != nil { + return 0, err + } if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %f", v) + return 0, fmt.Errorf("negative value cannot be coerced to uint64: %v", v) } return uint64(v), nil + case float32: + f := float64(v) + if err := checkExactIntegerFloat(f, maxExactFloat32Integer); err != nil { + return 0, err + } + if f < 0 { + return 0, fmt.Errorf("negative value cannot be coerced to uint64: %v", f) + } + return uint64(f), nil + case json.Number: + return jsonNumberToUint64(v) default: return 0, fmt.Errorf("cannot convert %T to uint64", value) } @@ -431,10 +573,31 @@ func coerceUint32(value interface{}) (uint32, error) { } return uint32(v), nil case float64: - if v < 0 || v > 4294967295 { + if err := checkExactIntegerFloat(v, maxExactFloat64Integer); err != nil { + return 0, err + } + if v < 0 || v > math.MaxUint32 { return 0, fmt.Errorf("value %f out of range for uint32", v) } return uint32(v), nil + case float32: + f := float64(v) + if err := checkExactIntegerFloat(f, maxExactFloat32Integer); err != nil { + return 0, err + } + if f < 0 || f > math.MaxUint32 { + return 0, fmt.Errorf("value %f out of range for uint32", f) + } + return uint32(f), nil + case json.Number: + u, err := jsonNumberToUint64(v) + if err != nil { + return 0, err + } + if u > math.MaxUint32 { + return 0, fmt.Errorf("value %d out of range for uint32", u) + } + return uint32(u), nil default: return 0, fmt.Errorf("cannot convert %T to uint32", value) } @@ -483,31 +646,81 @@ func coerceUint16(value interface{}) (uint16, error) { } return uint16(v), nil case float64: - if v < 0 || v > 65535 { + if err := checkExactIntegerFloat(v, maxExactFloat64Integer); err != nil { + return 0, err + } + if v < 0 || v > math.MaxUint16 { return 0, fmt.Errorf("value %f out of range for uint16", v) } return uint16(v), nil + case float32: + f := float64(v) + if err := checkExactIntegerFloat(f, maxExactFloat32Integer); err != nil { + return 0, err + } + if f < 0 || f > math.MaxUint16 { + return 0, fmt.Errorf("value %f out of range for uint16", f) + } + return uint16(f), nil + case json.Number: + u, err := jsonNumberToUint64(v) + if err != nil { + return 0, err + } + if u > math.MaxUint16 { + return 0, fmt.Errorf("value %d out of range for uint16", u) + } + return uint16(u), nil default: return 0, fmt.Errorf("cannot convert %T to uint16", value) } } -// coerceFloat64 converts various numeric types to float64 -// Mirrors server.go:getFloatFieldValidated behavior +// coerceFloat64 converts various numeric types to float64. +// Non-finite values are rejected: NaN and infinities are not representable in +// either BSSCI wire encoding's JSON form and are invalid field values. func coerceFloat64(value interface{}) (float64, error) { switch v := value.(type) { case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0, fmt.Errorf("non-finite value %v is not a valid field value", v) + } return v, nil case float32: - return float64(v), nil + f := float64(v) + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, fmt.Errorf("non-finite value %v is not a valid field value", f) + } + return f, nil case int64: return float64(v), nil case int: return float64(v), nil + case int32: + return float64(v), nil + case int16: + return float64(v), nil + case int8: + return float64(v), nil case uint64: return float64(v), nil + case uint: + return float64(v), nil case uint32: return float64(v), nil + case uint16: + return float64(v), nil + case uint8: + return float64(v), nil + case json.Number: + f, err := v.Float64() + if err != nil { + return 0, fmt.Errorf("invalid JSON number %q: %w", string(v), err) + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, fmt.Errorf("non-finite value %v is not a valid field value", f) + } + return f, nil default: return 0, fmt.Errorf("cannot convert %T to float64", value) } @@ -583,6 +796,15 @@ func numericToByte(value interface{}) (byte, error) { return 0, fmt.Errorf("value %f out of byte range (0-255)", v) } return byte(v), nil + case json.Number: + u, err := jsonNumberToUint64(v) + if err != nil { + return 0, err + } + if u > 255 { + return 0, fmt.Errorf("value %d out of byte range (0-255)", u) + } + return byte(u), nil default: return 0, fmt.Errorf("cannot convert %T to byte", value) } diff --git a/KC-Core/pkg/bssci/outbound_validation_test.go b/KC-Core/pkg/bssci/outbound_validation_test.go index 317e6af..fd2b0ca 100644 --- a/KC-Core/pkg/bssci/outbound_validation_test.go +++ b/KC-Core/pkg/bssci/outbound_validation_test.go @@ -341,11 +341,7 @@ func TestValidationMarshalFailure(t *testing.T) { } server.broadcastFn = server.SendAttachPropagateToAll - session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", - } + _ = server // Create a struct with unmarshalable field (channel type) type UnmarshalableMsg struct { @@ -360,7 +356,7 @@ func TestValidationMarshalFailure(t *testing.T) { Chan: make(chan int), } - err := server.validateOutboundMessage(session, invalidMsg) + _, err := outboundValidationProjection(invalidMsg) if err == nil { t.Error("Expected error for unmarshalable message") } @@ -407,7 +403,11 @@ func TestValidationStructMessages(t *testing.T) { SnScUuid: uuid, } - err := server.validateOutboundMessage(session, connectRsp) + projection, projErr := outboundValidationProjection(connectRsp) + if projErr != nil { + t.Fatalf("Projection failed for ConnectResponse struct: %v", projErr) + } + err := server.validateOutboundMessage(session, projection) if err != nil { t.Errorf("Validation failed for ConnectResponse struct: %v", err) } @@ -427,7 +427,11 @@ func TestValidationStructMessages(t *testing.T) { TxTime: &txTime, } - err := server.validateOutboundMessage(session, vmDlData) + projection, projErr := outboundValidationProjection(vmDlData) + if projErr != nil { + t.Fatalf("Projection failed for VMDLData struct: %v", projErr) + } + err := server.validateOutboundMessage(session, projection) if err != nil { t.Errorf("Validation failed for VMDLData struct: %v", err) } diff --git a/KC-Core/pkg/bssci/phase_c2_regression_test.go b/KC-Core/pkg/bssci/phase_c2_regression_test.go index c6c0149..e079f75 100644 --- a/KC-Core/pkg/bssci/phase_c2_regression_test.go +++ b/KC-Core/pkg/bssci/phase_c2_regression_test.go @@ -490,7 +490,7 @@ func Test_validateOutboundMessage_CatalogTokens(t *testing.T) { }, } - err := server.validateOutboundMessage(session, invalidMsg) + err := server.validateOutboundMessage(session, invalidMsg.Data.(map[string]interface{})) if err == nil { t.Error("Expected validation error for missing command field") } @@ -519,7 +519,7 @@ func Test_validateOutboundMessage_CatalogTokens(t *testing.T) { }, } - err = server2.validateOutboundMessage(session, validMsg.Data) + err = server2.validateOutboundMessage(session, validMsg.Data.(map[string]interface{})) if err != nil { t.Errorf("Valid message should not fail validation: %v", err) } diff --git a/KC-Core/pkg/bssci/protocol_edge_cases_test.go b/KC-Core/pkg/bssci/protocol_edge_cases_test.go index a76ad43..bb9447b 100644 --- a/KC-Core/pkg/bssci/protocol_edge_cases_test.go +++ b/KC-Core/pkg/bssci/protocol_edge_cases_test.go @@ -3,6 +3,7 @@ package bssci_test import ( "context" "encoding/json" + "fmt" "net" "testing" "time" @@ -125,9 +126,81 @@ func TestBSSCI_2_4_01_forward_compat_extra_fields(t *testing.T) { } } -// TestBSSCI_2_4_02_optional_fields_default verifies optional fields get defaults -// per BSSCI §2.4-02 (optional fields must have sensible defaults) -func TestBSSCI_2_4_02_optional_fields_default(t *testing.T) { +// TestBSSCI_5_3_2_minor_version_negotiated_down verifies conRsp version +// arbitration (rev1 §4.2, §5.3.2): a base station requesting a newer minor +// version is answered with the service center's selected version and the +// session continues. +func TestBSSCI_5_3_2_minor_version_negotiated_down(t *testing.T) { + for _, encoding := range []string{"json", "msgpack"} { + t.Run(encoding, func(t *testing.T) { + testLogger := logger.NewNop() + mockConn := &edgeMockConn{encoding: encoding} + mockConn.Reset() + + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, + queueSerializer, auditLogger, tenantResolver, mockStorage := + bssci.CreateTestServices(testLogger, nil) + + server := bssci.NewTestServer(testLogger, mockStorage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, &mockConnectionService{tenantID: 1}, broadcaster, + queueSerializer, auditLogger, tenantResolver) + + config := &bssci.Config{ + ServiceCenterEUI: bssci.TestScEui01, + Vendor: "test-vendor", + Model: "test-model", + Name: "test-sc", + SoftwareVersion: "1.0.0", + } + server.SetConfig(config) + server.SetConnectionManager(nil) + + session := &bssci.Session{ + ID: "test-session-negotiate-down", + Conn: mockConn, + Encoding: encoding, + } + + requestedMajor, requestedMinor, _, cerr := bssci.ParseVersion(mioty.MIOTYProtocolVersion) + require.Nil(t, cerr) + requested := fmt.Sprintf("%d.%d.%d", requestedMajor, requestedMinor+1, 0) + + data := map[string]interface{}{ + "version": requested, + "bsEui": bssci.TestBsEui01, + "bidi": true, + } + + msg := &bssci.Message{ + Command: "con", + OpId: 0, + Data: data, + } + + require.NoError(t, server.CallHandleConnect(session, msg, data), + "Newer-minor connect must negotiate down, not fail") + + assert.Equal(t, requested, session.ClientVersion, + "Raw base station version must be kept for audit") + assert.Equal(t, mioty.MIOTYProtocolVersion, session.NegotiatedVersion, + "Negotiated version must be the service center's selected version") + + var conRsp map[string]interface{} + for _, sentMsg := range mockConn.sentMessages { + if cmd, ok := sentMsg["command"].(string); ok && cmd == mioty.CmdConnectResponse { + conRsp = sentMsg + } + } + require.NotNil(t, conRsp, "conRsp must be sent for a negotiated connect") + assert.Equal(t, mioty.MIOTYProtocolVersion, conRsp["version"], + "conRsp must carry the selected version per §5.3.2") + }) + } +} + +// TestBSSCI_5_3_1_missing_version_rejected verifies the mandatory connect +// version field is enforced (rev1 §5.3.1) +func TestBSSCI_5_3_1_missing_version_rejected(t *testing.T) { for _, encoding := range []string{"json", "msgpack"} { t.Run(encoding, func(t *testing.T) { testLogger := logger.NewNop() @@ -158,7 +231,8 @@ func TestBSSCI_2_4_02_optional_fields_default(t *testing.T) { Encoding: encoding, } - // Connect without optional version field + // Connect without the mandatory version field (rev1 §5.3.1; + // message metadata declares version Required) data := map[string]interface{}{ "bsEui": bssci.TestBsEui01, "bidi": true, @@ -170,21 +244,24 @@ func TestBSSCI_2_4_02_optional_fields_default(t *testing.T) { Data: data, } - _ = server.CallHandleConnect(session, msg, data) + err := server.CallHandleConnect(session, msg, data) + require.NoError(t, err, + "Rejected connect awaits errorAck instead of closing (§5.17)") + assert.Equal(t, bssci.ConnectStateAwaitingConnectErrorAck, session.ConnectState, + "Rejected connect must await the base station's errorAck") - // VERIFY DEFAULT WAS APPLIED (not just absence of error) - assert.Equal(t, mioty.MIOTYProtocolVersion, session.NegotiatedVersion, - "Server must populate default version per §2.4-02") + // No version may be negotiated for a rejected connect + assert.Empty(t, session.NegotiatedVersion, + "Rejected connect must not populate a negotiated version") - // Should not send error about missing version + // An error frame must be sent to the base station + sawError := false for _, sentMsg := range mockConn.sentMessages { if cmd, ok := sentMsg["command"].(string); ok && cmd == "error" { - if errMsg, hasMsg := sentMsg["message"].(string); hasMsg { - assert.NotContains(t, errMsg, "version", - "Optional version should default per §2.4-02") - } + sawError = true } } + assert.True(t, sawError, "Missing mandatory version must produce an error frame") }) } } @@ -524,12 +601,12 @@ func TestNormalizationSentinelErrors(t *testing.T) { func TestConConditionalRules(t *testing.T) { testLogger := logger.NewNop() - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := bssci.CreateTestServices(testLogger, nil) server := bssci.NewTestServer(testLogger, mockStorage, nil, 1, - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, + sessionSvc, downlinkSvc, statusSvc, &mockConnectionService{tenantID: 1}, broadcaster, queueSerializer, auditLogger, tenantResolver) server.SetConfig(&bssci.Config{ ServiceCenterEUI: bssci.TestScEui01, @@ -538,6 +615,7 @@ func TestConConditionalRules(t *testing.T) { Name: "TestSC", SoftwareVersion: "1.0.0", }) + server.SetConnectionManager(nil) t.Run("NewConnect_NoResumeFields_Success", func(t *testing.T) { mockConn := &edgeMockConn{encoding: "json"} diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index 0875aaf..28fadf4 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -115,14 +115,15 @@ type Server struct { storage interfaces.Storage // Uses repository interfaces // Injected services - sessionSvc SessionService - downlinkSvc DownlinkService - statusSvc StatusService - connectionSvc ConnectionService - broadcaster SCACIBroadcaster - queueSerializer QueueSerializer // Downlink response frame builder - auditLogger AuditLogger // Downlink audit event recorder - tenantResolver TenantResolver // Queue-to-tenant mapping (replaces queueTenants map) + sessionSvc SessionService + versionNegotiator VersionNegotiator + downlinkSvc DownlinkService + statusSvc StatusService + connectionSvc ConnectionService + broadcaster SCACIBroadcaster + queueSerializer QueueSerializer // Downlink response frame builder + auditLogger AuditLogger // Downlink audit event recorder + tenantResolver TenantResolver // Queue-to-tenant mapping (replaces queueTenants map) // Organization resolution orgResolver org.Resolver // Organization UUID → tenant ID resolution @@ -199,11 +200,39 @@ type Config struct { OrgEnforcementEnabled bool // Require valid X-Organization-ID in gRPC metadata MessageEncoding string // BSSCI Section 1: default message encoding (json/msgpack) DetachSignatureValidationEnabled bool // BSSCI §5.7.1: Enable detach signature validation + // OperationAckTimeout bounds how long the service center waits for the + // next handshake message (conCmp or errorAck) after sending conRsp or a + // connect-stage error. Also bounds reads before the connect operation. + OperationAckTimeout time.Duration + // DuplicateWindow is the uplink deduplication window + DuplicateWindow time.Duration + // CertificatePollInterval is the certificate change poll interval + CertificatePollInterval time.Duration // DisableAttachPersistence is TEST-ONLY: skips DB persistence in attach handler. // MUST remain false in production. Used by tests to exercise replay protection without transaction stubs. DisableAttachPersistence bool } +// ConnectState tracks the connect operation handshake per BSSCI §3.3/§5.17: +// con initiates the operation, conCmp completes it, and an error replaces the +// normal sequence with error followed by errorAck. +type ConnectState int + +// Connect handshake states. A session is provisional until ConnectStateComplete; +// provisional sessions never enter the live-session maps or the resumable index. +const ( + // ConnectStateAwaitingConnect: no con received yet + ConnectStateAwaitingConnect ConnectState = iota + // ConnectStateAwaitingConnectComplete: conRsp sent, waiting for conCmp + ConnectStateAwaitingConnectComplete + // ConnectStateAwaitingConnectErrorAck: error sent, waiting for errorAck + ConnectStateAwaitingConnectErrorAck + // ConnectStateComplete: handshake finished, session active + ConnectStateComplete + // ConnectStateTerminal: handshake failed or connection closing + ConnectStateTerminal +) + // Session represents a connected Base Station session // //revive:disable:var-naming BsOpId/ScOpId/LastBsOpId/LastScOpId use lowercase 'd' per MIOTY BSSCI §3.2 @@ -244,17 +273,19 @@ type Session struct { OrganizationID uuid.UUID // Kilo Cloud org UUID (from TLS cert or community fallback) ResolvedTenantID int64 // Tenant ID resolved from cert/org (vs server default s.tenantID) ClientCert *x509.Certificate // TLS client certificate for org resolution - mu sync.Mutex + // Connect handshake state machine (BSSCI §3.3/§5.17) + ConnectState ConnectState + // pendingBaseStation caches the registration looked up during the connect + // request so connect-complete does not repeat the lookup + pendingBaseStation *basestation.BaseStation + mu sync.Mutex } // BaseStationEUIBytes converts the BaseStationEUI uint64 to a byte slice. // This is needed for roaming service calls that expect []byte parameters. func (s *Session) BaseStationEUIBytes() []byte { - euiBytes := make([]byte, 8) - for i := 0; i < 8; i++ { - euiBytes[i] = byte(s.BaseStationEUI >> (8 * (7 - i))) - } - return euiBytes + euiBytes := mioty.EUI64(s.BaseStationEUI).ToBytes() + return euiBytes[:] } // Message represents a BSSCI protocol message @@ -286,60 +317,43 @@ func tryExtractFromMap(v map[string]interface{}) (string, int64, bool) { return "", 0, false // Neither field present } - // Extract opId - handle int64 or float64 + // Extract opId via the canonical operation ID parsing (BSSCI §5.2) opIdVal, hasOpId := v["opId"] if !hasOpId { return "", 0, false } - var opId int64 - switch typed := opIdVal.(type) { - case int64: - opId = typed - case float64: - opId = int64(typed) - default: + opId, ok := parseOpID(opIdVal) + if !ok { return "", 0, false // Wrong type, needs JSON fallback } return command, opId, true } -// wrapOutboundMessage converts interface{} to *Message for consistent RawPayload capture -// Handles *Message, map[string]interface{}, and typed structs (ConnectResponse, etc.) via JSON round-trip +// outboundEnvelope exposes the wire envelope (command, opId) of typed BSSCI +// messages embedding mioty.BaseMessage without serialization round-trips. +type outboundEnvelope interface { + EnvelopeCommand() string + EnvelopeOpID() int64 +} + +// wrapOutboundMessage converts interface{} to *Message for consistent RawPayload capture. +// Map payloads keep their original values; typed structs (ConnectResponse, etc.) +// are retained as the original typed payload so uint64 fields such as scEui are +// encoded exactly (no JSON float64 projection). func (s *Server) wrapOutboundMessage(msg interface{}) (*Message, error) { switch v := msg.(type) { case *Message: return v, nil case map[string]interface{}: - // Try direct extraction first command, opId, ok := tryExtractFromMap(v) if !ok { - // Fall back to JSON round-trip normalization - jsonBytes, err := json.Marshal(v) - if err != nil { - return nil, fmt.Errorf("failed to marshal map: %w", err) - } - var normalized map[string]interface{} - if err := json.Unmarshal(jsonBytes, &normalized); err != nil { - return nil, fmt.Errorf("failed to unmarshal map: %w", err) - } - - // Retry extraction after normalization - command, opId, ok = tryExtractFromMap(normalized) - if !ok { - return nil, fmt.Errorf("map missing command/opId after normalization") - } - - // Populate normalized map with extracted values - normalized["command"] = command - normalized["opId"] = opId - v = normalized // Callers see sanitized values - } else { - // Direct extraction succeeded - ensure keys are normalized in original map - v["command"] = command - v["opId"] = opId + return nil, fmt.Errorf("map missing command/opId envelope") } + // Ensure canonical envelope keys on the original map + v["command"] = command + v["opId"] = opId return &Message{ Command: command, @@ -347,44 +361,42 @@ func (s *Server) wrapOutboundMessage(msg interface{}) (*Message, error) { Data: v, }, nil default: - // Handle typed structs (ConnectResponse, PingResponse, etc.) via JSON marshaling - jsonBytes, err := json.Marshal(msg) - if err != nil { - return nil, fmt.Errorf("failed to marshal message: %w", err) - } - var msgMap map[string]interface{} - if err := json.Unmarshal(jsonBytes, &msgMap); err != nil { - return nil, fmt.Errorf("failed to unmarshal message: %w", err) - } - - // Extract command from commandType or command field (MIOTY spec variance) - command, ok := msgMap["commandType"].(string) + env, ok := msg.(outboundEnvelope) if !ok { - command, ok = msgMap["command"].(string) - if !ok { - return nil, fmt.Errorf("message missing command/commandType field (type: %T)", msg) - } + return nil, fmt.Errorf("message type %T does not expose a BSSCI envelope", msg) } - - // Extract opId (JSON unmarshals numbers as float64) - opId, ok := msgMap["opId"].(float64) - if !ok { - return nil, fmt.Errorf("message missing opId field (type: %T)", msg) + command := env.EnvelopeCommand() + if command == "" { + return nil, fmt.Errorf("message missing command/commandType field (type: %T)", msg) } - // CRITICAL: Populate msgMap with normalized values for validation - // Without this, validateOutboundMessage fails with missing command/opId - msgMap["command"] = command - msgMap["opId"] = int64(opId) - return &Message{ Command: command, - OpId: int64(opId), - Data: msgMap, + OpId: env.EnvelopeOpID(), + Data: msg, }, nil } } +// outboundValidationProjection builds the map used for outbound field-catalog +// validation. Typed payloads are projected through MessagePack (uint64-exact); +// map payloads are validated directly. The projection is only inspected - +// encoding always uses the original payload. +func outboundValidationProjection(payload interface{}) (map[string]interface{}, error) { + if m, ok := payload.(map[string]interface{}); ok { + return m, nil + } + raw, err := msgpack.Marshal(payload) + if err != nil { + return nil, &CatalogError{Token: errOutboundMarshalFailed, Posix: POSIX_EPROTO} + } + var projection map[string]interface{} + if err := msgpack.Unmarshal(raw, &projection); err != nil { + return nil, &CatalogError{Token: errOutboundMarshalFailed, Posix: POSIX_EPROTO} + } + return projection, nil +} + // HandlerFunc handles a specific command type HandlerFunc func(s *Server, session *Session, msg *Message, data map[string]interface{}) error @@ -547,24 +559,10 @@ func (s *Server) resolveEndpointTenantID(ctx context.Context, session *Session, } // validateOutboundMessage validates an outbound message complies with BSSCI field catalog. +// The map is a validation projection of the payload (see outboundValidationProjection); +// it is inspected only and never replaces the encoded payload. // Returns nil on success, CatalogError on validation failure. -func (s *Server) validateOutboundMessage(session *Session, message interface{}) error { - // Marshal to map for field inspection - var msgMap map[string]interface{} - switch v := message.(type) { - case map[string]interface{}: - msgMap = v - default: - // Convert via JSON round-trip - jsonBytes, err := json.Marshal(message) - if err != nil { - return &CatalogError{Token: errOutboundMarshalFailed, Posix: POSIX_EPROTO} - } - if err := json.Unmarshal(jsonBytes, &msgMap); err != nil { - return &CatalogError{Token: errOutboundMarshalFailed, Posix: POSIX_EPROTO} - } - } - +func (s *Server) validateOutboundMessage(session *Session, msgMap map[string]interface{}) error { // Check command field exists cmdVal, hasCommand := msgMap["command"] if !hasCommand { @@ -643,6 +641,7 @@ func NewServer( endpointRepo interfaces.EndpointRepository, tenantID int64, sessionSvc SessionService, + versionNegotiator VersionNegotiator, downlinkSvc DownlinkService, statusSvc StatusService, connectionSvc ConnectionService, @@ -675,7 +674,7 @@ func NewServer( cancel: cancel, handlers: make(map[string]HandlerFunc), connectionMgr: connectionMgr, - deduplicator: NewMessageDeduplicator(5 * time.Minute), // 5 minute dedup window per MIOTY spec + deduplicator: NewMessageDeduplicator(duplicateWindow(cfg)), storage: stor, tenantID: tenantID, eventStore: eventStore, @@ -683,14 +682,15 @@ func NewServer( endpointRepo: endpointRepo, keyEncryptor: keyEncryptor, // Injected services - sessionSvc: sessionSvc, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionSvc: connectionSvc, - queueSerializer: queueSerializer, - auditLogger: auditLogger, - tenantResolver: tenantResolver, - broadcaster: broadcaster, + sessionSvc: sessionSvc, + versionNegotiator: versionNegotiator, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionSvc: connectionSvc, + queueSerializer: queueSerializer, + auditLogger: auditLogger, + tenantResolver: tenantResolver, + broadcaster: broadcaster, // Organization resolution orgResolver: orgResolver, defaultTenantID: defaultTenantID, @@ -894,8 +894,7 @@ func (s *Server) Start() error { func (s *Server) waitForCertsAndStart() { defer s.wg.Done() - const pollInterval = 10 * time.Second - ticker := time.NewTicker(pollInterval) + ticker := time.NewTicker(certificatePollInterval(s.config)) defer ticker.Stop() for { @@ -1105,6 +1104,9 @@ func (s *Server) handleConnection(conn net.Conn) { // Ensure we update status to offline when connection ends defer func() { + wasActive := session.ConnectState == ConnectStateComplete + session.ConnectState = ConnectStateTerminal + // Stop status mechanism safely session.mu.Lock() if session.stopStatus != nil { @@ -1113,23 +1115,26 @@ func (s *Server) handleConnection(conn net.Conn) { } session.mu.Unlock() - if session.BaseStationEUI != 0 && s.connectionMgr != nil { - // Convert uint64 EUI to [8]byte format - var euiBytes [8]byte - for i := 7; i >= 0; i-- { - euiBytes[i] = byte(session.BaseStationEUI >> (8 * (7 - i))) - } + ctx := s.sessionContext(session) - // Update connection status to offline - status := &basestation.ConnectionStatus{ - IsOnline: false, - LastSeen: time.Now(), - ConnectionType: basestation.ConnectionTypeBSSCI, - SessionID: session.ID, - } + // Session-map cleanup runs unconditionally so rejected or provisional + // connections never linger in the live maps + s.mu.Lock() + delete(s.sessions, session.ID) + s.mu.Unlock() - ctx := s.sessionContext(session) - if err := s.connectionMgr.UpdateConnectionStatus(ctx, euiBytes, status); err != nil { + // Also remove from SessionService's sessionsByUUID map to prevent stale resume + if s.sessionSvc != nil { + s.sessionSvc.RemoveSession(session) + } + + // Offline status transition is guarded by connection identity: a + // reconnect that already replaced this connection keeps the base + // station online + if session.BaseStationEUI != 0 && s.connectionMgr != nil { + euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() + + if err := s.connectionMgr.DisconnectBaseStationIfCurrent(ctx, euiBytes, session.ID); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateOfflineStatus, "eui", session.BaseStationEUI, "error", err) @@ -1138,44 +1143,44 @@ func (s *Server) handleConnection(conn net.Conn) { "eui", session.BaseStationEUI, "name", session.Name) } + } - // Remove from sessions map - s.mu.Lock() - delete(s.sessions, session.ID) - s.mu.Unlock() - - // Also remove from SessionService's sessionsByUUID map to prevent stale resume - s.sessionSvc.RemoveSession(session) - - // Terminate database session per BSSCI §3 "new session starts, discarding state" - if session.DbSessionID != 0 && s.sessionSvc != nil { - if err := s.sessionSvc.TerminateSession(ctx, session); err != nil { + // A completed session lost unexpectedly stays resumable; anything + // else that was persisted is terminated (BSSCI §3.3 lifecycle) + if session.DbSessionID != 0 && s.sessionSvc != nil { + if wasActive { + if err := s.sessionSvc.MarkDisconnected(ctx, session); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToTerminateSession, "error", err, "sessionID", session.DbSessionID, "eui", session.BaseStationEUI) - } else { - s.logger.InfoContext(ctx, LogBSSCISessionTerminated, - "sessionID", session.DbSessionID, - "eui", session.BaseStationEUI) } + } else if err := s.sessionSvc.TerminateSession(ctx, session); err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToTerminateSession, + "error", err, + "sessionID", session.DbSessionID, + "eui", session.BaseStationEUI) + } else { + s.logger.InfoContext(ctx, LogBSSCISessionTerminated, + "sessionID", session.DbSessionID, + "eui", session.BaseStationEUI) } + } - // Clean up pending operations for this session (bulk delete) - if session.DbSessionID != 0 && s.storage != nil { - // Guard against nil repository before calling methods - pendingOpsRepo := s.storage.PendingOperations() - if pendingOpsRepo != nil { - count, err := pendingOpsRepo.DeleteBySession(ctx, session.DbSessionID) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToDeletePendingOperations, - "error", err, - "sessionID", session.DbSessionID) - } else if count > 0 { - s.logger.InfoContext(ctx, LogBSSCIDeletedPendingOperations, - "sessionID", session.DbSessionID, - "count", count) - } + // Clean up pending operations for this session (bulk delete) + if session.DbSessionID != 0 && s.storage != nil { + // Guard against nil repository before calling methods + pendingOpsRepo := s.storage.PendingOperations() + if pendingOpsRepo != nil { + count, err := pendingOpsRepo.DeleteBySession(ctx, session.DbSessionID) + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToDeletePendingOperations, + "error", err, + "sessionID", session.DbSessionID) + } else if count > 0 { + s.logger.InfoContext(ctx, LogBSSCIDeletedPendingOperations, + "sessionID", session.DbSessionID, + "count", count) } } } @@ -1189,8 +1194,15 @@ func (s *Server) handleConnection(conn net.Conn) { default: } - // Set read deadline - if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { + // Read deadline follows the handshake state: bounded while the connect + // operation is in flight (con/conRsp/error awaiting their next message), + // cleared once the session is active - liveness of active sessions is + // the ping operation's job (BSSCI §5.4) + deadline := time.Time{} + if session.ConnectState != ConnectStateComplete { + deadline = time.Now().Add(s.operationAckTimeout()) + } + if err := conn.SetReadDeadline(deadline); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSetReadDeadline, "error", err) return } @@ -1231,7 +1243,7 @@ func (s *Server) handleConnection(conn net.Conn) { // Persist encoding to database if session has been persisted if session.DbSessionID > 0 { - if err := s.sessionSvc.UpdateEncoding(s.safeCtx(), session.DbSessionID, encoding); err != nil { + if err := s.sessionSvc.UpdateEncoding(s.safeCtx(), resolvedTenant(session, s.tenantID), session.DbSessionID, encoding); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToPersistEncoding, "encoding", encoding, "sessionID", session.DbSessionID, @@ -1313,10 +1325,7 @@ func (s *Server) handleConnection(conn net.Conn) { // Update last seen in database for connected basestations if session.BaseStationEUI != 0 && s.connectionMgr != nil { - var euiBytes [8]byte - for i := 7; i >= 0; i-- { - euiBytes[i] = byte(session.BaseStationEUI >> (8 * (7 - i))) - } + euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() // Update last seen time in database go func() { @@ -1389,6 +1398,11 @@ func (s *Server) handleMessage(session *Session, msg *Message, data map[string]i if sendErr := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errToken)); sendErr != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToSendErrorResponse, "error", sendErr) } + // A malformed connect request enters the handshake error sequence: + // the error above replaces conRsp and awaits errorAck (§5.17) + if msg.Command == mioty.CmdConnect && session.ConnectState == ConnectStateAwaitingConnect { + session.ConnectState = ConnectStateAwaitingConnectErrorAck + } return nil // Error sent via protocol, don't close connection } // Use normalized data for handler (unknown fields removed, types coerced) @@ -1453,26 +1467,27 @@ func (s *Server) sendMessage(session *Session, msg interface{}) error { return fmt.Errorf("failed to wrap outbound message: %w", err) } - // BSSCI §2.5.1: Validate outbound message fields before encoding - if err := s.validateOutboundMessage(session, outMsg.Data); err != nil { - // Check if this is a catalog error that should be sent to the base station + // BSSCI §2.5.1: Validate outbound message fields before encoding. + // Validation inspects a projection only; failures are returned to the + // caller - an invalid outbound frame must never trigger sending a protocol + // error in its place. + projection, err := outboundValidationProjection(outMsg.Data) + if err != nil { + return fmt.Errorf("outbound validation failed: %w", err) + } + if err := s.validateOutboundMessage(session, projection); err != nil { var catalogErr *CatalogError if errors.As(err, &catalogErr) { - // Send catalog error to base station per BSSCI protocol - ctx := s.sessionContext(session) - s.logger.ErrorContext(ctx, LogBSSCIOutboundValidationFailed, + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIOutboundValidationFailed, "error_token", catalogErr.Token, + "command", outMsg.Command, "bs_eui", session.BaseStationEUI) - if sendErr := s.sendCatalogError(session, outMsg.OpId, catalogErr); sendErr != nil { - return fmt.Errorf("failed to send catalog error: %w", sendErr) - } - return nil // Error sent via protocol, don't propagate } - // Non-catalog errors are internal failures return fmt.Errorf("outbound validation failed: %w", err) } - // Encode message using negotiated encoding (BSSCI Section 1) + // Encode the original payload (typed or map) using the negotiated encoding + // (BSSCI Section 1); uint64 fields survive exactly payload, err := encodeMessage(outMsg.Data, session.Encoding) if err != nil { return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToEncode), err) @@ -1506,55 +1521,105 @@ func (s *Server) sendMessage(session *Session, msg interface{}) error { return nil } +// operationAckTimeout returns the configured handshake wait bound, falling +// back to the package default when unset. +func (s *Server) operationAckTimeout() time.Duration { + if s.config != nil && s.config.OperationAckTimeout > 0 { + return s.config.OperationAckTimeout + } + return defaultOperationAckTimeout +} + +// duplicateWindow returns the configured uplink deduplication window, falling +// back to the package default when unset. +func duplicateWindow(cfg *Config) time.Duration { + if cfg != nil && cfg.DuplicateWindow > 0 { + return cfg.DuplicateWindow + } + return defaultDuplicateWindow +} + +// certificatePollInterval returns the configured certificate change poll +// interval, falling back to the package default when unset. +func certificatePollInterval(cfg *Config) time.Duration { + if cfg != nil && cfg.CertificatePollInterval > 0 { + return cfg.CertificatePollInterval + } + return defaultCertificatePollInterval +} + +// rejectConnect fails the connect operation per BSSCI §5.17: an error frame +// replaces the normal response, the session enters AwaitingConnectErrorAck, +// and the connection stays open until the base station acknowledges with +// errorAck or the handshake read deadline expires. The connection is never +// closed immediately after sending the error. +func (s *Server) rejectConnect(session *Session, opId int64, posix int, errToken string) error { + if err := s.sendError(session, opId, posix, ResolveErrorMessage(errToken)); err != nil { + s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) + // Error frame could not be written - the connection is unusable + session.ConnectState = ConnectStateTerminal + return fmt.Errorf("%s: %w", ResolveErrorMessage(errToken), err) + } + session.ConnectState = ConnectStateAwaitingConnectErrorAck + return nil +} + // handleConnect handles the connect operation per BSSCI specification func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data map[string]interface{}) error { + // Connect messages are only valid while no connect operation is in flight + if session.ConnectState != ConnectStateAwaitingConnect { + s.logger.WarnContext(s.safeCtx(), LogBSSCIRejectingCommandBeforeHandshake, + "command", msg.Command, + "connectState", int(session.ConnectState), + "opId", msg.OpId) + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errInvalidHandshakeState) + } + // BSSCI-3.2-03: Connect operation MUST use opId=0 if msg.OpId != 0 { s.logger.WarnContext(s.safeCtx(), LogBSSCIInvalidConnectOperationID, "op_id", msg.OpId) - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errInvalidConnectOpId)); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return fmt.Errorf("%s: %d", ResolveErrorMessage(errInvalidConnectOpId), msg.OpId) + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errInvalidConnectOpId) } - // Extract version field (optional per BSSCI §2.4-02, defaults to canonical version) - version := getStringField(data, "version", mioty.MIOTYProtocolVersion) - bsEUI, ok := getNumericField(data, "bsEui") - if !ok { - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errMissingBsEui)); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return fmt.Errorf("%s", ResolveErrorMessage(errMissingBsEui)) + // version is mandatory in the connect message (BSSCI rev1 §5.3.1; + // message metadata declares it Required) + version, hasVersion := data["version"].(string) + if !hasVersion { + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errMandatoryFieldMissing) + } + bsEUIRaw, hasBsEUI := data["bsEui"] + if !hasBsEUI { + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errMissingBsEui) } - // BSSCI-2.1-01, BSSCI-2.2-02: Version negotiation (delegate to SessionService) - if err := s.sessionSvc.ValidateVersion(version); err != nil { - if catErr, ok := err.(*CatalogError); ok { + // BSSCI-2.1-01, BSSCI-2.2-02: Version arbitration (rev1 §4.2, §5.3.2) - + // the negotiator selects the version the service center will speak; the + // conRsp carries it and the base station agrees via conCmp or rejects + selectedVersion, negErr := s.versionNegotiator.Negotiate(s.safeCtx(), version) + if negErr != nil { + var catErr *CatalogError + if errors.As(negErr, &catErr) { s.logger.WarnContext(s.safeCtx(), LogBSSCIVersionIncompatible, "client_version", version, "error", catErr.Token) - return s.sendCatalogError(session, msg.OpId, catErr) + return s.rejectConnect(session, msg.OpId, catErr.Posix, catErr.Token) } // Fallback for unexpected errors - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errVersionIncompatible)); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return fmt.Errorf("%s", ResolveErrorMessage(errVersionIncompatible)) + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errVersionIncompatible) } - // Range guard for int64 -> uint64 conversion - if bsEUI < 0 { - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errInvalidBsEui)); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return fmt.Errorf("%s: bsEUI=%d", ResolveErrorMessage(errInvalidBsEui), bsEUI) + // bsEui is a full-range unsigned EUI-64 (BSSCI §5.3.1); values above + // INT64_MAX are valid + bsEUI, euiErr := coerceUint64(bsEUIRaw) + if euiErr != nil { + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errInvalidBsEui) } // Simple session field assignments - session.BaseStationEUI = uint64(bsEUI) - session.ClientVersion = version // Raw BS-provided version for audit - session.NegotiatedVersion = mioty.MIOTYProtocolVersion // SC canonical version (BSSCI §4-4.5) + session.BaseStationEUI = bsEUI + session.ClientVersion = version // Raw BS-provided version for audit + session.NegotiatedVersion = selectedVersion // Negotiated version carried in conRsp (BSSCI §5.3.2) session.Vendor = getStringField(data, "vendor", "") session.Model = getStringField(data, "model", "") session.Name = getStringField(data, "name", "") @@ -1573,30 +1638,59 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m // BSSCI §5.3: bidi flag must be explicitly declared bidiValue, hasBidi := data["bidi"] if !hasBidi { - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errMissingBidiFlag)); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return fmt.Errorf("%s", ResolveErrorMessage(errMissingBidiFlag)) + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errMissingBidiFlag) } bidi, ok := bidiValue.(bool) if !ok { - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errMissingBidiFlag)); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return fmt.Errorf("%s: not a boolean", ResolveErrorMessage(errMissingBidiFlag)) + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errMissingBidiFlag) } session.Bidirectional = bidi - // Geolocation parsing: all three values must parse and lat/lon must be in range + // Registration and tenant authorization precede the connect response + // (§5.3.2): an unregistered or unauthorized base station is rejected via + // the error/errorAck sequence before any conRsp is offered + ctx := s.sessionContext(session) + euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() + baseStation, bsErr := s.connectionSvc.GetBaseStationGlobal(ctx, euiBytes, s.connectionMgr) + if bsErr != nil || baseStation == nil { + s.logger.ErrorContext(ctx, LogBSSCIBaseStationNotFoundInDatabase, + "eui", session.BaseStationEUI, + "euiHex", fmt.Sprintf("%X", euiBytes), + "error", bsErr) + return s.rejectConnect(session, msg.OpId, POSIX_EPERM, errBaseStationNotRegistered) + } + + if s.config.OrgEnforcementEnabled { + // Certificate-resolved tenant and registered tenant must agree; the + // rejection never reveals that the EUI exists under another tenant + if session.ResolvedTenantID != baseStation.TenantID { + s.logger.WarnContext(ctx, LogBSSCIBaseStationNotFoundInDatabase, + "eui", session.BaseStationEUI, + "certTenant", session.ResolvedTenantID) + return s.rejectConnect(session, msg.OpId, POSIX_EPERM, errBaseStationNotRegistered) + } + } else if baseStation.TenantID > 0 { + // Community fallback: adopt the registered base station tenant and + // its default organization + session.ResolvedTenantID = baseStation.TenantID + if s.orgResolver != nil { + if orgID, orgErr := s.orgResolver.GetDefaultOrgForTenant(ctx, baseStation.TenantID); orgErr == nil { + session.OrganizationID = orgID + } + } + ctx = s.sessionContext(session) + } + session.pendingBaseStation = baseStation + + // Geolocation parsing: exactly three finite numeric values with lat/lon in range if geoLoc, ok := data["geoLocation"].([]interface{}); ok && len(geoLoc) == 3 { - lat, latOk := geoLoc[0].(float64) - lon, lonOk := geoLoc[1].(float64) - alt, altOk := geoLoc[2].(float64) - if latOk && lonOk && altOk && - lat >= models.LatitudeMin && lat <= models.LatitudeMax && - lon >= models.LongitudeMin && lon <= models.LongitudeMax { - session.GeoLocation = []float64{lat, lon, alt} + if coords, valid := extractFloatSlice(geoLoc); valid { + lat, lon, alt := coords[0], coords[1], coords[2] + if lat >= models.LatitudeMin && lat <= models.LatitudeMax && + lon >= models.LongitudeMin && lon <= models.LongitudeMax { + session.GeoLocation = []float64{lat, lon, alt} + } } } @@ -1605,37 +1699,30 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m var previousSession *Session var err error - // Extract snBsUuid and scUUIDToMatch (simple map access) + // Resume identity is snBsUuid scoped by tenant and base station EUI + // (rev1 §5.3.1: the con message carries snBsUuid, snBsOpId, snScOpId - + // there is no snScUuid field in the connect request) if snBsUUIDData, hasBsUUID := data["snBsUuid"]; hasBsUUID { bsUUID, catErr := extractSessionUUID(snBsUUIDData) if catErr != nil { s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToExtractSnBsUUID, "error", catErr.Token) - return s.sendCatalogError(session, msg.OpId, catErr) + return s.rejectConnect(session, msg.OpId, catErr.Posix, catErr.Token) } // Valid bsUUID extracted - continue with resume logic session.BsUUID = bsUUID - // Extract scUUIDToMatch from extra field if present - var scUUIDToMatch []byte - if extra, hasExtra := data["extra"].(map[string]interface{}); hasExtra { - if snScUUIDData, hasScUUID := extra["snScUuid"]; hasScUUID { - if scUUID, catErr := extractSessionUUID(snScUUIDData); catErr == nil { - scUUIDToMatch = scUUID - } - } - } - - // Extract operation counters - var bsOpId, scOpId int64 + // Extract optional operation counter constraints (absent means the + // constraint is not asserted per BSSCI §5.3.1) + var bsOpId, scOpId *int64 if bsOp, ok := getNumericField(data, "snBsOpId"); ok { - bsOpId = bsOp + bsOpId = &bsOp } if scOp, ok := getNumericField(data, "snScOpId"); ok { - scOpId = scOp + scOpId = &scOp } // Delegate resume validation to SessionService - previousSession, err = s.sessionSvc.HandleResume(session, bsUUID, scUUIDToMatch, bsOpId, scOpId, session.BaseStationEUI) + previousSession, err = s.sessionSvc.HandleResume(ctx, session, bsUUID, bsOpId, scOpId, session.BaseStationEUI) if err != nil { // Log error but continue with new session s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToCheckSessionResume, @@ -1643,15 +1730,26 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m "eui", session.BaseStationEUI) } + // A resumable session must belong to the tenant authorized above; + // cross-tenant UUID collisions never resume + if previousSession != nil && previousSession.ResolvedTenantID != session.ResolvedTenantID { + s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToCheckSessionResume, + "eui", session.BaseStationEUI, + "sessionTenant", session.ResolvedTenantID, + "previousTenant", previousSession.ResolvedTenantID) + previousSession = nil + } + if previousSession != nil { - // Valid resume - copy state from previous session + // Valid resume - restore the authoritative persisted counters, + // not the constraint values reported in the connect message snResume = true session.IsResumed = true session.SessionUUID = previousSession.SessionUUID - session.BsOpId = bsOpId - session.ScOpId = scOpId - session.LastBsOpId = bsOpId - session.LastScOpId = scOpId + session.BsOpId = previousSession.LastBsOpId + session.ScOpId = previousSession.LastScOpId + session.LastBsOpId = previousSession.LastBsOpId + session.LastScOpId = previousSession.LastScOpId session.DbSessionID = previousSession.DbSessionID session.OrganizationID = previousSession.OrganizationID // Restore org from previous session session.ResolvedTenantID = previousSession.ResolvedTenantID // Restore tenant from previous session @@ -1677,13 +1775,8 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m "eui", session.BaseStationEUI) } - // Store session in sessions map (protocol handling) - s.mu.Lock() - s.sessions[session.ID] = session - s.mu.Unlock() - - // Delegate UUID-based session storage to SessionService - s.sessionSvc.StoreSessionByUUID(session) + // The session stays provisional until conCmp: live-session map and + // resumable-index registration happen in handleConnectComplete s.logger.InfoContext(s.safeCtx(), LogBSSCIBaseStationConnected, "eui", session.BaseStationEUI, @@ -1730,18 +1823,31 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m response.SnScOpId = &session.ScOpId } - return s.sendMessage(session, response) + if err := s.sendMessage(session, response); err != nil { + session.ConnectState = ConnectStateTerminal + return err + } + session.ConnectState = ConnectStateAwaitingConnectComplete + return nil } -// handleConnectComplete handles the connect complete operation +// handleConnectComplete handles the connect complete operation. The connect +// operation is already complete at the protocol level when conCmp arrives, so +// failures past this point never send a BSSCI error - partial persistence is +// compensated and the connection closed for the base station to reconnect. func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message, _ map[string]interface{}) error { + // conCmp is only valid after conRsp was sent + if session.ConnectState != ConnectStateAwaitingConnectComplete { + s.logger.WarnContext(s.safeCtx(), LogBSSCIRejectingCommandBeforeHandshake, + "command", msg.Command, + "connectState", int(session.ConnectState), + "opId", msg.OpId) + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errInvalidHandshakeState) + } + // Validate that connect complete has opId == 0 (BSSCI-3.3) if msg.OpId != 0 { - errDef := GetErrorDefinition(errInvalidConnectCompleteOpId) - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, errDef.Message); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return fmt.Errorf("%s: %d", errDef.Message, msg.OpId) + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errInvalidConnectCompleteOpId) } s.logger.InfoContext(s.safeCtx(), LogBSSCIBaseStationConnectionCompletedSuccessfully, @@ -1749,61 +1855,31 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message "name", session.Name, "sessionID", session.ID) - // Convert uint64 EUI to [8]byte format for lookups - var euiBytes [8]byte - for i := 7; i >= 0; i-- { - euiBytes[i] = byte(session.BaseStationEUI >> (8 * (7 - i))) - } ctx := s.sessionContext(session) - // Global EUI lookup — tenant not yet known at connect time - baseStation, err := s.connectionSvc.GetBaseStationGlobal(ctx, euiBytes, s.connectionMgr) - if err != nil || baseStation == nil { - s.logger.ErrorContext(ctx, LogBSSCIBaseStationNotFoundInDatabase, - "eui", session.BaseStationEUI, - "euiHex", fmt.Sprintf("%X", euiBytes), - "error", err) - - // Send error response using catalog - if sendErr := s.sendError(session, msg.OpId, POSIX_EPERM, ResolveErrorMessage(errBaseStationNotRegistered)); sendErr != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToSendErrorResponse, "error", sendErr) - } + // Registration and tenant authorization already ran before conRsp + baseStation := session.pendingBaseStation + if baseStation == nil { + session.ConnectState = ConnectStateTerminal return fmt.Errorf("%s: EUI %016X", ResolveErrorMessage(errBaseStationNotRegistered), session.BaseStationEUI) } - // Repair session tenant to match base station's registered tenant - if baseStation.TenantID > 0 { - session.ResolvedTenantID = baseStation.TenantID - if s.orgResolver != nil { - orgID, orgErr := s.orgResolver.GetDefaultOrgForTenant(ctx, baseStation.TenantID) - if orgErr == nil { - session.OrganizationID = orgID - } - } - // Rebuild context with correct tenant for all subsequent operations - ctx = s.sessionContext(session) - } - - s.logger.InfoContext(ctx, LogBSSCIBaseStationFoundInDatabase, - "eui", session.BaseStationEUI, - "name", session.Name, - "tenant_id", baseStation.TenantID) - - // Delegate database session persistence to SessionService + // Persist the session; the operation is protocol-complete, so a + // persistence failure closes the connection without a BSSCI error if err := s.sessionSvc.PersistSession(ctx, session, baseStation, session.IsResumed, session.ConnectInfo); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToPersistSession, "error", err, "eui", session.BaseStationEUI) - // Continue - session can still function without DB persistence + session.ConnectState = ConnectStateTerminal + return fmt.Errorf("session persistence failed after conCmp: %w", err) } - // Store session in local sessions map with DbSessionID populated - if session.DbSessionID > 0 { - s.mu.Lock() - s.sessions[session.ID] = session - s.mu.Unlock() - // SessionsByUUID storage is handled by SessionService in handleConnect - } + // Activation: only a fully persisted session enters the live-session map + // and the resumable index + s.mu.Lock() + s.sessions[session.ID] = session + s.mu.Unlock() + s.sessionSvc.StoreSessionByUUID(session) // Update the base station's bidi capability and GPS location in the database if s.basestationRepo != nil { @@ -1839,6 +1915,15 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message // BSSCI-3.3-03: Delegate handshake completion to SessionService s.sessionSvc.MarkHandshakeComplete(session) + session.ConnectState = ConnectStateComplete + + // Active sessions read without a deadline; liveness is the ping + // operation's job (BSSCI §5.4) + if session.Conn != nil { + if err := session.Conn.SetReadDeadline(time.Time{}); err != nil { + s.logger.WarnContext(ctx, LogBSSCIFailedToSetReadDeadline, "error", err) + } + } // Start status mechanism for this session s.startStatusMechanism(session) @@ -2119,8 +2204,8 @@ func (s *Server) InitiatePing(ctx context.Context, baseStationEUI uint64, tenant func (s *Server) handleAttach(_ *Server, session *Session, msg *Message, data map[string]interface{}) error { ctx := s.sessionContext(session) - // Extract and validate mandatory epEui field - epEUI, hasEpEUI := getNumericField(data, "epEui") + // Extract and validate mandatory epEui field (full-range unsigned EUI-64) + epEUI, hasEpEUI := getUint64Field(data, "epEui") if !hasEpEUI { return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errMissingEpEui)) } @@ -2245,7 +2330,7 @@ func (s *Server) handleAttach(_ *Server, session *Session, msg *Message, data ma // Store pending operation for completion tracking // Make a copy of the data to avoid mutations pendingData := map[string]interface{}{ - "epEui": pkgmioty.FormatEUI64(uint64(epEUI)), + "epEui": pkgmioty.FormatEUI64(epEUI), "attachCnt": attachCnt, "rxTime": rxTime, "nonce": nonce, @@ -2288,11 +2373,7 @@ func (s *Server) handleAttach(_ *Server, session *Session, msg *Message, data ma // Convert endpoint EUI to bytes for database lookup epEUIBytes := make([]byte, 8) - // EUIs are always positive in MIOTY protocol - if epEUI < 0 { - return s.sendError(session, msg.OpId, POSIX_EINVAL, ResolveErrorMessage(errInvalidFieldValue)) - } - binary.BigEndian.PutUint64(epEUIBytes[:], uint64(epEUI)) + binary.BigEndian.PutUint64(epEUIBytes[:], epEUI) // Look up endpoint in database to get network keys var nwkSnKey []byte @@ -2415,7 +2496,7 @@ func (s *Server) handleAttach(_ *Server, session *Session, msg *Message, data ma } //nolint:gosec // G115: attachCnt validated to be <= 0xFFFFFF on line 1431, safe for uint32 - if err := ValidateAttachSignature(uint64(epEUI), uint32(attachCnt), sign, nwkSnKey); err != nil { + if err := ValidateAttachSignature(epEUI, uint32(attachCnt), sign, nwkSnKey); err != nil { s.logger.WarnContext(s.safeCtx(), LogBSSCIAttachSignatureValidationFailed, "epEui", epEUI, "error", err) @@ -2425,7 +2506,7 @@ func (s *Server) handleAttach(_ *Server, session *Session, msg *Message, data ma return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errInvalidSignature)) } - sessionKey, err := DeriveSessionKey(uint64(epEUI), nonce, sign, nwkSnKey) + sessionKey, err := DeriveSessionKey(epEUI, nonce, sign, nwkSnKey) if err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToDeriveSessionKey, "epEui", epEUI, @@ -2695,8 +2776,8 @@ func (s *Server) handleAttach(_ *Server, session *Session, msg *Message, data ma // handleDetach handles detach operations per BSSCI 3.7 func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data map[string]interface{}) error { - // Extract and validate mandatory epEui field - epEUI, hasEpEUI := getNumericField(data, "epEui") + // Extract and validate mandatory epEui field (full-range unsigned EUI-64) + epEUI, hasEpEUI := getUint64Field(data, "epEui") if !hasEpEUI { return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errMissingEpEui)) } @@ -2763,17 +2844,13 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma "signLen", len(sign)) // Build typed detachMetadata struct for crash-safe persistence (BSSCI §5.7.1) - epEuiUint, errToken := safeUint64(epEUI, "epEui") - if errToken != "" { - return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errToken)) - } packetCntUint, errToken := safeUint32(packetCnt, "packetCnt") if errToken != "" { return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errToken)) } typedMetadata := &detachMetadata{ - EpEui: epEuiUint, + EpEui: epEUI, PacketCnt: packetCntUint, Signature: sign, RxTime: rxTime, @@ -2794,7 +2871,7 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma // Fetch endpoint early to resolve owner tenant/org for roaming support (DET-02) ctx := s.sessionContext(session) euiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(euiBytes, epEuiUint) + binary.BigEndian.PutUint64(euiBytes, epEUI) // Try same-tenant lookup first, then cross-tenant fallback for roaming endpoint, err := s.endpointRepo.GetByEUI(ctx, resolvedTenant(session, s.tenantID), euiBytes) @@ -2833,23 +2910,23 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma // Unknown endpoint - validate via internal validator and use returned tenant metadata (BSSCI §5.7) s.logger.WarnContext(ctx, LogBSSCIDetachFromUnknownEndpoint, "error", err, - "epEui", epEuiUint) + "epEui", epEUI) // Validate unknown endpoint signature via internal detach validator service if s.detachValidator != nil { - result, validationErr := s.detachValidator.ValidateDetachSignature(ctx, epEuiUint, sign) + result, validationErr := s.detachValidator.ValidateDetachSignature(ctx, epEUI, sign) if validationErr != nil { // Check for typed sentinel: endpoint not found if errors.Is(validationErr, ErrDetachValidationEndpointNotFound) { s.logger.WarnContext(ctx, LogBSSCIUnknownEndpointNotFoundDuringDetachValidation, - "epEui", epEuiUint) + "epEui", epEUI) return s.sendError(session, msg.OpId, POSIX_ENOENT, ResolveErrorMessage(errEndpointNotFound)) } // Check for signature validation failure with metadata if errors.Is(validationErr, ErrDetachSignatureInvalid) && result != nil { s.logger.WarnContext(ctx, LogBSSCIUnknownEndpointDetachSignatureInvalid, - "epEui", epEuiUint, + "epEui", epEUI, "tenant_id", result.TenantID, "owner_tenant_id", result.OwnerTenantID, "validation_status", result.ValidationStatus) @@ -2858,7 +2935,7 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma // All other errors treated as signature validation failure s.logger.WarnContext(ctx, LogBSSCIUnknownEndpointDetachSignatureValidationFailed, - "epEui", epEuiUint, + "epEui", epEUI, "error", validationErr) return s.sendError(session, msg.OpId, POSIX_EACCES, ResolveErrorMessage(errDetachSignatureInvalid)) } @@ -2891,7 +2968,7 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma } s.logger.InfoContext(ctx, LogBSSCIUnknownEndpointDetachSignatureValidatedSuccessfully, - "epEui", epEuiUint, + "epEui", epEUI, "tenantId", ownerTenantID, "ownerTenantId", result.OwnerTenantID, "validationStatus", validationStatus) @@ -2900,7 +2977,7 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma ownerTenantID = resolvedTenant(session, s.tenantID) ownerCtx = pkgcontext.WithTenantID(context.Background(), ownerTenantID) s.logger.WarnContext(ctx, LogBSSCIDetachValidatorNotConfiguredUsingSessionTenantForUnknownEndpoint, - "epEui", epEuiUint, + "epEui", epEUI, "session_tenant", ownerTenantID) } } @@ -3035,7 +3112,7 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma if err := s.storage.MIOTYMessages().CreateDetachMessage(ownerCtx, detachMsg, normalizedPayload); err != nil { s.logger.WarnContext(ownerCtx, LogBSSCIFailedToPersistDetachMessage, "error", err, - "epEui", epEuiUint, + "epEui", epEUI, "bsEui", session.BaseStationEUI, "opId", msg.OpId) } @@ -3047,7 +3124,7 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma if s.config.DetachSignatureValidationEnabled { if err := ValidateDetachSignature(sign, endpoint.Sign); err != nil { s.logger.WarnContext(ownerCtx, LogBSSCIDetachSignatureValidationFailed, - "epEui", pkgmioty.FormatEUI64(epEuiUint), + "epEui", pkgmioty.FormatEUI64(epEUI), "error", err) return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errInvalidSignature)) } @@ -3068,7 +3145,7 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma if err := s.endpointRepo.UpdateFields(ownerCtx, ownerTenantID, endpoint.ID, updates); err != nil { s.logger.WarnContext(ownerCtx, LogBSSCIFailedToUpdateEndpointDetachTelemetry, "error", err, - "epEui", epEuiUint) + "epEui", epEUI) } } @@ -3236,7 +3313,7 @@ func (s *Server) handleAttachComplete(_ *Server, session *Session, msg *Message, // Tier 2: EUI lookup with session tenant (legacy ops) if endpoint == nil && epEUI != 0 { euiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(euiBytes, uint64(epEUI)) + binary.BigEndian.PutUint64(euiBytes, epEUI) sessionTenant := resolvedTenant(session, s.tenantID) endpoint, _ = s.endpointRepo.GetByEUI(s.safeCtx(), sessionTenant, euiBytes) } @@ -3244,7 +3321,7 @@ func (s *Server) handleAttachComplete(_ *Server, session *Session, msg *Message, // Tier 3: Cross-tenant EUI lookup (roaming fallback) if endpoint == nil && epEUI != 0 { euiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(euiBytes, uint64(epEUI)) + binary.BigEndian.PutUint64(euiBytes, epEUI) var eui models.EUI copy(eui[:], euiBytes) endpoint, _ = s.endpointRepo.Get(s.safeCtx(), eui) @@ -3385,8 +3462,8 @@ func (s *Server) handleDetachComplete(_ *Server, session *Session, msg *Message, } if epEUI == 0 { // Final fallback to current message - if epEuiRaw, ok := getNumericField(data, "epEui"); ok { - epEUI = uint64(epEuiRaw) //nolint:gosec // G115: EUIs from message validated as positive + if epEuiRaw, ok := getUint64Field(data, "epEui"); ok { + epEUI = epEuiRaw } } @@ -3491,7 +3568,7 @@ func (s *Server) handleDetachComplete(_ *Server, session *Session, msg *Message, // Record event for successful detach if s.eventStore != nil && epEUI != 0 { details := map[string]interface{}{ - "epEui": pkgmioty.FormatEUI64(uint64(epEUI)), // Use hex string to avoid JSON precision loss + "epEui": pkgmioty.FormatEUI64(epEUI), // Use hex string to avoid JSON precision loss "bsEui": pkgmioty.FormatEUI64(session.BaseStationEUI), "operation": "detach_complete", } @@ -3503,7 +3580,7 @@ func (s *Server) handleDetachComplete(_ *Server, session *Session, msg *Message, EventType: models.EventTypeEndpointDetached, Category: mioty.CategoryEndpoint, Severity: SeverityInfo, - Title: fmt.Sprintf(models.EventTitleEndpointDetachedViaBS, pkgmioty.FormatEUI64(uint64(epEUI)), pkgmioty.FormatEUI64(session.BaseStationEUI)), + Title: fmt.Sprintf(models.EventTitleEndpointDetachedViaBS, pkgmioty.FormatEUI64(epEUI), pkgmioty.FormatEUI64(session.BaseStationEUI)), Description: "Endpoint successfully detached from network", Details: detailsJSON, Status: EventStatusNew, @@ -3619,18 +3696,15 @@ func (s *Server) handleDetachComplete(_ *Server, session *Session, msg *Message, func (s *Server) handleULData(_ *Server, session *Session, msg *Message, data map[string]interface{}) error { ctx := s.sessionContext(session) - epEUI, ok := getNumericField(data, "epEui") + // epEui is a full-range unsigned EUI-64 (BSSCI §5.10.1) + epEUI, ok := getUint64Field(data, "epEui") if !ok { return fmt.Errorf("%s", ResolveErrorMessage(errMissingEpEui)) } // Route by endpoint ownership before committing to ingest if s.dispositionResolver != nil { - epEuiForDisp, errToken := safeUint64(epEUI, "epEui") - if errToken != "" { - return s.sendError(session, msg.OpId, POSIX_EINVAL, ResolveErrorMessage(errToken)) - } - disposition, dispErr := s.dispositionResolver.Resolve(ctx, epEuiForDisp) + disposition, dispErr := s.dispositionResolver.Resolve(ctx, epEUI) if dispErr != nil { s.logger.ErrorContext(ctx, LogBSSCIDispositionResolutionFailedRejectingUplink, "ep_eui", epEUI, "error", dispErr) @@ -3651,8 +3725,7 @@ func (s *Server) handleULData(_ *Server, session *Session, msg *Message, data ma // Send ulDataRsp only if the insert succeeds; on failure send a BSSCI error // so the base station retries rather than silently losing the packet. rawFrame, _ := json.Marshal(data) // best-effort; frame already validated above - epU, _ := safeUint64(epEUI, "epEui") - _, enqErr := s.relayOutbox.Enqueue(ctx, epU, session.BaseStationEUI, rawFrame, time.Now().UnixNano()) + _, enqErr := s.relayOutbox.Enqueue(ctx, epEUI, session.BaseStationEUI, rawFrame, time.Now().UnixNano()) if enqErr != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToEnqueueRelayUplink, "ep_eui", epEUI, "error", enqErr) @@ -3765,10 +3838,7 @@ func (s *Server) handleULData(_ *Server, session *Session, msg *Message, data ma return fmt.Errorf("uplinkIngestSvc is required for handleULData") } { - epEuiVal, errToken := safeUint64(epEUI, "epEui") - if errToken != "" { - return s.sendError(session, msg.OpId, POSIX_EINVAL, ResolveErrorMessage(errToken)) - } + epEuiVal := epEUI packetCntVal, errToken := safeUint32(packetCnt, "packetCnt") if errToken != "" { return s.sendError(session, msg.OpId, POSIX_EINVAL, ResolveErrorMessage(errToken)) @@ -3855,6 +3925,22 @@ func (s *Server) handleError(_ *Server, session *Session, msg *Message, data map "message", message, "baseStation", session.BaseStationEUI) + // Handshake error routing (BSSCI §5.17): an error with opId 0 while + // waiting for conCmp means the base station rejected the offered version + // or connect response. The service center acknowledges with errorAck, + // which completes the failed connect operation, and closes. + if msg.OpId == 0 && session.ConnectState == ConnectStateAwaitingConnectComplete { + errorAckMsg := map[string]interface{}{ + "command": mioty.CmdErrorAck, + "opId": msg.OpId, + } + if sendErr := s.sendMessage(session, errorAckMsg); sendErr != nil { + s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorAck, "error", sendErr) + } + session.ConnectState = ConnectStateTerminal + return fmt.Errorf("base station rejected connect response: code=%d %s", code, message) + } + // Check if this error is related to a pending operation // Base stations sometimes send error messages instead of proper response messages // We must complete the three-way handshake to prevent timeout loops @@ -3962,6 +4048,19 @@ func (s *Server) handleErrorAck(_ *Server, session *Session, msg *Message, _ map "opId", msg.OpId, "baseStationEUI", session.BaseStationEUI) + // Handshake errorAck routing (BSSCI §5.17): the acknowledgement completes + // the failed connect operation; the connection closes + if msg.OpId == 0 && session.ConnectState == ConnectStateAwaitingConnectErrorAck { + session.ConnectState = ConnectStateTerminal + return fmt.Errorf("connect operation failed and was acknowledged by the base station") + } + + // An errorAck for the connect operation outside the awaiting state is a + // protocol-ordering violation + if msg.OpId == 0 && session.ConnectState != ConnectStateComplete { + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errInvalidHandshakeState) + } + // For SC-initiated operations (negative opId), clean up pending operation // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both cache and DB removal if msg.OpId < 0 { @@ -4060,12 +4159,7 @@ func decodeMessage(rawFrame []byte, encoding string) (map[string]interface{}, er switch encoding { case EncodingJSON: - // Trim BOM and leading whitespace per RFC 8259 - trimmed := trimJSONWhitespace(rawFrame) - if err := json.Unmarshal(trimmed, &data); err != nil { - return nil, err - } - return data, nil + return decodeJSONFrame(rawFrame) case EncodingMessagePack: if err := msgpack.Unmarshal(rawFrame, &data); err != nil { return nil, err @@ -4080,6 +4174,26 @@ func decodeMessage(rawFrame []byte, encoding string) (map[string]interface{}, er } } +// decodeJSONFrame decodes a JSON-encoded BSSCI frame strictly: numbers are +// preserved as json.Number (the full uint64 EUI range survives decoding), the +// frame must contain exactly one JSON object, and trailing content is rejected. +func decodeJSONFrame(rawFrame []byte) (map[string]interface{}, error) { + // Trim BOM and leading whitespace per RFC 8259 + trimmed := trimJSONWhitespace(rawFrame) + + dec := json.NewDecoder(bytes.NewReader(trimmed)) + dec.UseNumber() + + var data map[string]interface{} + if err := dec.Decode(&data); err != nil { + return nil, err + } + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%s: trailing content after JSON frame", ResolveErrorMessage(errInvalidMessageFormat)) + } + return data, nil +} + func getStringField(data map[string]interface{}, key string, defaultValue string) string { if v, ok := data[key].(string); ok { return v @@ -4087,90 +4201,47 @@ func getStringField(data map[string]interface{}, key string, defaultValue string return defaultValue } +// getNumericField extracts a signed 64-bit protocol field. Unsigned overflow, +// non-integral floats, and float magnitudes beyond the exact integer range are +// rejected via the canonical numeric coercion (coerceInt64). func getNumericField(data map[string]interface{}, key string) (int64, bool) { value, exists := data[key] if !exists { return 0, false } + v, err := coerceInt64(value) + if err != nil { + return 0, false + } + return v, true +} - switch v := value.(type) { - case int64: - return v, true - case int: - return int64(v), true - case int8: - return int64(v), true - case int16: - return int64(v), true - case int32: - return int64(v), true - case float64: - return int64(v), true - case float32: - return int64(float64(v)), true - case uint: - return int64(v), true //nolint:gosec // G115: BSSCI protocol values fit int64 range - case uint8: - return int64(v), true - case uint16: - return int64(v), true - case uint32: - return int64(v), true - case uint64: - return int64(v), true //nolint:gosec // G115: BSSCI protocol values fit int64 range - default: +// getUint64Field extracts an unsigned 64-bit protocol field (e.g. an EUI-64), +// preserving the full uint64 range including values above INT64_MAX. Negative +// values, non-integral floats, and float magnitudes beyond the exact integer +// range are rejected via the canonical numeric coercion (coerceUint64). +func getUint64Field(data map[string]interface{}, key string) (uint64, bool) { + value, exists := data[key] + if !exists { return 0, false } + v, err := coerceUint64(value) + if err != nil { + return 0, false + } + return v, true } // parseOpID validates and extracts operation ID from interface{} value. -// Per BSSCI §5.2, operation IDs must be precise 64-bit integers. -// Rejects non-integral floats and uint64 values that overflow int64 range. +// Per BSSCI §5.2, operation IDs must be precise 64-bit integers; the canonical +// numeric coercion rejects non-integral floats and uint64 overflow. // Returns (value, true) on success or (0, false) on validation failure. func parseOpID(value interface{}) (int64, bool) { - switch v := value.(type) { - case int64: - return v, true - case int: - return int64(v), true - case int8: - return int64(v), true - case int16: - return int64(v), true - case int32: - return int64(v), true - case float64: - // Only accept if no fractional part and within int64 range - if v != math.Trunc(v) || v < math.MinInt64 || v > math.MaxInt64 { - return 0, false - } - return int64(v), true - case float32: - f64 := float64(v) - // Only accept if no fractional part and within int64 range - if f64 != math.Trunc(f64) || f64 < math.MinInt64 || f64 > math.MaxInt64 { - return 0, false - } - return int64(f64), true - case uint: - if v > math.MaxInt64 { - return 0, false - } - return int64(v), true - case uint8: - return int64(v), true - case uint16: - return int64(v), true - case uint32: - return int64(v), true - case uint64: - if v > math.MaxInt64 { - return 0, false - } - return int64(v), true - default: + v, err := coerceInt64(value) + if err != nil { return 0, false } + return v, true } func getBoolField(data map[string]interface{}, key string, defaultValue bool) bool { @@ -4187,43 +4258,19 @@ func getNumericFieldInt(data map[string]interface{}, key string, defaultValue in return defaultValue } -// getFloatFieldValidated extracts a float field and validates it's actually numeric +// getFloatFieldValidated extracts a float field and validates it's actually +// numeric via the canonical numeric coercion (coerceFloat64). // Returns the value and true if field exists and is numeric, or 0 and false otherwise func getFloatFieldValidated(data map[string]interface{}, key string) (float64, bool) { value, exists := data[key] if !exists { return 0, false } - - switch v := value.(type) { - case float64: - return v, true - case float32: - return float64(v), true - case int64: - return float64(v), true - case int: - return float64(v), true - case int8: - return float64(v), true - case int16: - return float64(v), true - case int32: - return float64(v), true - case uint: - return float64(v), true - case uint8: - return float64(v), true - case uint16: - return float64(v), true - case uint32: - return float64(v), true - case uint64: - return float64(v), true - default: - // Invalid type for numeric field + v, err := coerceFloat64(value) + if err != nil { return 0, false } + return v, true } // validateByteArray validates a MessagePack byte array (e.g., 4-byte nonce/sign). @@ -4250,30 +4297,15 @@ func validateByteArray(data interface{}, fieldName string, expectedLen int) ([]b } result := make([]byte, expectedLen) for i, elem := range v { - // Extract numeric value - var numVal float64 - switch e := elem.(type) { - case float64: - numVal = e - case int64: - numVal = float64(e) - case int: - numVal = float64(e) - default: - // Non-numeric element - if fieldName == fieldNameNonce { - return nil, errInvalidNonceElement - } - return nil, errInvalidSignElement - } - // Validate range: must be integer 0-255 - if numVal < 0 || numVal > 255 || numVal != float64(int(numVal)) { + // Canonical numeric coercion enforces integer 0-255 range + b, err := numericToByte(elem) + if err != nil { if fieldName == fieldNameNonce { return nil, errInvalidNonceElement } return nil, errInvalidSignElement } - result[i] = byte(numVal) + result[i] = b } return result, "" default: @@ -4423,32 +4455,18 @@ func mapToDetachMetadata(m map[string]interface{}) *detachMetadata { } func parseMetadataEUI(value interface{}) (uint64, bool) { - switch v := value.(type) { - case string: - parsed, err := validation.ParseEUI(v) + if s, ok := value.(string); ok { + parsed, err := validation.ParseEUI(s) if err != nil { return 0, false } return parsed, true - case uint64: - return v, true - case int64: - if v < 0 { - return 0, false - } - return uint64(v), true - case float64: - if v < 0 { - return 0, false - } - const maxSafeFloat64Int = 1 << 53 - if v > float64(maxSafeFloat64Int) { - return 0, false - } - return uint64(v), true - default: + } + v, err := coerceUint64(value) + if err != nil { return 0, false } + return v, true } // normalizeDetachPayload converts raw detach message payload to concrete types for JSONB storage. @@ -4557,86 +4575,34 @@ func NormalizeSubpackets(raw map[string]interface{}) (*mioty.Subpackets, error) return sp, nil } -// extractFloatSlice validates and extracts float64 slice from MessagePack array. +// extractFloatSlice validates and extracts float64 slice from a wire array +// via the canonical numeric coercion (coerceFloat64). // Rejects non-numeric values. Returns (slice, true) on success or (nil, false) on failure. func extractFloatSlice(values []interface{}) ([]float64, bool) { result := make([]float64, len(values)) for i, v := range values { - switch val := v.(type) { - case float64: - result[i] = val - case float32: - result[i] = float64(val) - case int64: - result[i] = float64(val) - case int: - result[i] = float64(val) - case int8: - result[i] = float64(val) - case int16: - result[i] = float64(val) - case int32: - result[i] = float64(val) - case uint: - result[i] = float64(val) - case uint8: - result[i] = float64(val) - case uint16: - result[i] = float64(val) - case uint32: - result[i] = float64(val) - case uint64: - result[i] = float64(val) - default: + f, err := coerceFloat64(v) + if err != nil { // Non-numeric value - reject entire array return nil, false } + result[i] = f } return result, true } -// extractInt64Slice validates and extracts int64 slice from MessagePack array. +// extractInt64Slice validates and extracts int64 slice from a wire array via +// the canonical numeric coercion (coerceInt64). // Rejects non-integers and non-numeric values. Returns (slice, true) on success or (nil, false) on failure. func extractInt64Slice(values []interface{}) ([]int64, bool) { result := make([]int64, len(values)) for i, v := range values { - switch val := v.(type) { - case float64: - // Only accept if no fractional part - if val != float64(int64(val)) || val < math.MinInt64 || val > math.MaxInt64 { - return nil, false - } - result[i] = int64(val) - case int64: - result[i] = val - case int: - result[i] = int64(val) - case int8: - result[i] = int64(val) - case int16: - result[i] = int64(val) - case int32: - result[i] = int64(val) - case uint: - if val > math.MaxInt64 { - return nil, false - } - result[i] = int64(val) - case uint8: - result[i] = int64(val) - case uint16: - result[i] = int64(val) - case uint32: - result[i] = int64(val) - case uint64: - if val > math.MaxInt64 { - return nil, false - } - result[i] = int64(val) - default: + n, err := coerceInt64(v) + if err != nil { // Non-numeric or non-integer value - reject entire array return nil, false } + result[i] = n } return result, true } @@ -4805,38 +4771,16 @@ func (s *Server) normalizeUserDataField(userDataRaw interface{}) []byte { // Convert Numeric[n] to []byte userData := make([]byte, len(v)) for i, elem := range v { - switch num := elem.(type) { - case uint8: - userData[i] = num - case int8: - userData[i] = byte(num) - case uint16: - userData[i] = byte(num) - case int16: - userData[i] = byte(num) - case uint32: - userData[i] = byte(num) - case int32: - userData[i] = byte(num) - case uint64: - userData[i] = byte(num) - case int64: - userData[i] = byte(num) - case int: - userData[i] = byte(num) - case uint: - userData[i] = byte(num) - case float32: - userData[i] = byte(num) - case float64: - userData[i] = byte(num) - default: - // Log warning for unsupported element type + // Canonical numeric coercion enforces integer 0-255 range + b, err := numericToByte(elem) + if err != nil { s.logger.WarnContext(s.safeCtx(), LogBSSCIUnsupportedUserDataElementType, "index", i, "type", fmt.Sprintf("%T", elem)) userData[i] = 0 + continue } + userData[i] = b } return userData case string: @@ -6356,7 +6300,7 @@ func (s *Server) removePendingOperation(session *Session, opId int64) error { // loadPendingOperations loads pending operations from database for session resume (Issue #3: accepts *Session for SessionSlug field) func (s *Server) loadPendingOperations(session *Session) ([]*PendingOperation, error) { sessionID := session.DbSessionID - if sessionID == 0 { + if sessionID == 0 || s.storage == nil { s.logger.DebugContext(s.safeCtx(), LogBSSCIDatabaseNotAvailableForPendingOpsLoad) return nil, nil } @@ -6837,34 +6781,6 @@ func (s *Server) sendCatalogError(session *Session, opId int64, err *CatalogErro return fmt.Errorf("%s", err.Token) } -// ParseVersion parses a version string "major.minor.patch" (BSSCI-2.1-01) -// Returns specific CatalogError for each failure type to preserve diagnostic precision -// Exported for use by service layer implementations -func ParseVersion(version string) (major, minor, patch int, cerr *CatalogError) { - parts := strings.Split(version, ".") - if len(parts) != 3 { - return 0, 0, 0, NewCatalogError(errInvalidVersionFormat, POSIX_EPROTO) - } - - var err error - major, err = strconv.Atoi(parts[0]) - if err != nil { - return 0, 0, 0, NewCatalogError(errInvalidMajorVersion, POSIX_EPROTO) - } - - minor, err = strconv.Atoi(parts[1]) - if err != nil { - return 0, 0, 0, NewCatalogError(errInvalidMinorVersion, POSIX_EPROTO) - } - - patch, err = strconv.Atoi(parts[2]) - if err != nil { - return 0, 0, 0, NewCatalogError(errInvalidPatchVersion, POSIX_EPROTO) - } - - return major, minor, patch, nil -} - // validateOperationID validates operation IDs per BSSCI-3.2 requirements func (s *Server) validateOperationID(session *Session, opId int64, isBaseStationInitiated bool) string { // Connect operation must use ID 0 (BSSCI-3.2-03) @@ -6947,21 +6863,17 @@ func extractSessionUUID(data interface{}) ([]byte, *CatalogError) { } uuid := make([]byte, 16) for i, val := range v { - switch b := val.(type) { - case float64: - uuid[i] = byte(b) - case int: - uuid[i] = byte(b) - case int64: - uuid[i] = byte(b) - case uint8: - uuid[i] = b - case int8: - // Handle negative int8 values properly - uuid[i] = byte(b) - default: + if b, ok := val.(int8); ok { + // MessagePack encodes bytes above 0x7F as negative int8; + // the two's-complement bit pattern is the intended byte + uuid[i] = byte(b) //nolint:gosec // G115: intentional two's-complement byte extraction + continue + } + b, err := numericToByte(val) + if err != nil { return nil, &CatalogError{Token: errInvalidUUIDByteType, Posix: POSIX_EPROTO} } + uuid[i] = b } return uuid, nil case []byte: @@ -6976,17 +6888,15 @@ func extractSessionUUID(data interface{}) ([]byte, *CatalogError) { // updateSessionCounters updates the session operation counters in the database func (s *Server) updateSessionCounters(session *Session) { - if session.DbSessionID == 0 { + if session.DbSessionID == 0 || s.storage == nil { return } - // Update the session operation counters via repository - // Convert SessionUUID from []byte to [16]byte - var sessionUUID [16]byte - copy(sessionUUID[:], session.SessionUUID) - err := s.storage.BaseStationSessions().UpdateCountersAndTimestamp( + // Update the session operation counters via the tenant-scoped repository + err := s.storage.BaseStationSessions().UpdateOperationIDs( s.safeCtx(), - sessionUUID, + resolvedTenant(session, s.tenantID), + session.DbSessionID, session.LastBsOpId, session.LastScOpId) diff --git a/KC-Core/pkg/bssci/tenant_context_test.go b/KC-Core/pkg/bssci/tenant_context_test.go index 5e50ad0..c4f31bf 100644 --- a/KC-Core/pkg/bssci/tenant_context_test.go +++ b/KC-Core/pkg/bssci/tenant_context_test.go @@ -20,7 +20,7 @@ func (s *stubSessionService) ValidateVersion(_ string) error { return nil // No-op for smoke tests } -func (s *stubSessionService) HandleResume(_ *Session, _ []byte, _ []byte, _, _ int64, _ uint64) (*Session, error) { +func (s *stubSessionService) HandleResume(_ context.Context, _ *Session, _ []byte, _, _ *int64, _ uint64) (*Session, error) { return nil, nil // No-op for smoke tests } @@ -40,11 +40,15 @@ func (s *stubSessionService) RemoveSession(_ *Session) { // No-op for smoke tests } +func (s *stubSessionService) MarkDisconnected(_ context.Context, _ *Session) error { + return nil +} + func (s *stubSessionService) TerminateSession(_ context.Context, _ *Session) error { return nil // No-op for smoke tests } -func (s *stubSessionService) UpdateEncoding(_ context.Context, _ int64, _ string) error { +func (s *stubSessionService) UpdateEncoding(_ context.Context, _, _ int64, _ string) error { return nil // No-op for smoke tests } diff --git a/KC-Core/pkg/bssci/test_helpers_test.go b/KC-Core/pkg/bssci/test_helpers_test.go index 2fafc1b..d77ba76 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -12,11 +12,34 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" ) // NewTestServer creates a Server instance for testing with access to unexported fields. // This helper lives in package bssci (not bssci_test) so it can set internal fields. // Uses interfaces.Storage for dependency inversion. StatusService is mandatory (panics if nil). + +// staticTestVersionNegotiator mirrors the production negotiator semantics for +// the supported set {mioty.MIOTYProtocolVersion}: same-major requests select +// the canonical service center version; other majors and lower minors return +// the production catalog errors. +type staticTestVersionNegotiator struct{} + +func (staticTestVersionNegotiator) Negotiate(_ context.Context, requested string) (string, error) { + reqMajor, reqMinor, _, cerr := ParseVersion(requested) + if cerr != nil { + return "", cerr + } + scMajor, scMinor, _, _ := ParseVersion(mioty.MIOTYProtocolVersion) + if reqMajor != scMajor { + return "", NewCatalogError(ErrUnsupportedMajorVersion, POSIX_EPROTO) + } + if reqMinor < scMinor { + return "", NewCatalogError(ErrUnsupportedMinorVersion, POSIX_EPROTO) + } + return mioty.MIOTYProtocolVersion, nil +} + func NewTestServer( log logger.Logger, storage interfaces.Storage, @@ -37,20 +60,21 @@ func NewTestServer( } s := &Server{ - logger: log, - storage: storage, - eventStore: eventStore, - tenantID: tenantID, - sessionSvc: sessionSvc, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionSvc: connectionSvc, - broadcaster: broadcaster, - queueSerializer: queueSerializer, - auditLogger: auditLogger, - tenantResolver: tenantResolver, - handlers: make(map[string]HandlerFunc), // Initialize handlers map for tests - sessions: make(map[string]*Session), // Initialize sessions map for tests + logger: log, + storage: storage, + eventStore: eventStore, + tenantID: tenantID, + sessionSvc: sessionSvc, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionSvc: connectionSvc, + broadcaster: broadcaster, + queueSerializer: queueSerializer, + auditLogger: auditLogger, + tenantResolver: tenantResolver, + versionNegotiator: staticTestVersionNegotiator{}, + handlers: make(map[string]HandlerFunc), // Initialize handlers map for tests + sessions: make(map[string]*Session), // Initialize sessions map for tests } // Wire endpointRepo if storage is available (prevents nil panics in resolveEndpointTenantID) if storage != nil { @@ -263,19 +287,20 @@ func NewTestServerWithBaseStationRepo( } s := &Server{ - logger: log, - storage: storage, - basestationRepo: basestationRepo, - tenantID: tenantID, - sessionSvc: sessionSvc, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionSvc: connectionSvc, - broadcaster: broadcaster, - queueSerializer: queueSerializer, - auditLogger: auditLogger, - tenantResolver: tenantResolver, - sessions: make(map[string]*Session), + logger: log, + storage: storage, + basestationRepo: basestationRepo, + tenantID: tenantID, + sessionSvc: sessionSvc, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionSvc: connectionSvc, + broadcaster: broadcaster, + queueSerializer: queueSerializer, + auditLogger: auditLogger, + tenantResolver: tenantResolver, + versionNegotiator: staticTestVersionNegotiator{}, + sessions: make(map[string]*Session), } // Initialize broadcast hook (tests can override) s.broadcastFn = s.SendAttachPropagateToAll @@ -342,25 +367,23 @@ func newMockSessionService() *mockSessionService { } } -func (m *mockSessionService) ValidateVersion(version string) error { - // Accept any non-empty version for tests - if version == "" { - return fmt.Errorf("version required") - } - return nil -} - -func (m *mockSessionService) HandleResume(_ *Session, bsUUID []byte, _ []byte, bsOpId, scOpId int64, _ uint64) (*Session, error) { +func (m *mockSessionService) HandleResume(_ context.Context, _ *Session, bsUUID []byte, bsOpId, scOpId *int64, _ uint64) (*Session, error) { m.mu.RLock() defer m.mu.RUnlock() // Look up session by UUID using uppercase hex (matches production) key := strings.ToUpper(hex.EncodeToString(bsUUID)) if existingSession, exists := m.sessionsByUUID[key]; exists { - // Validate counters match - if existingSession.BsOpId == bsOpId && existingSession.ScOpId == scOpId { - return existingSession, nil + // Mirror production constraint semantics: absent constraints pass; + // required BS operation ID beyond known state or claimed SC + // operation ID beyond issued state rejects + if bsOpId != nil && *bsOpId > existingSession.LastBsOpId { + return nil, nil + } + if scOpId != nil && *scOpId < existingSession.LastScOpId { + return nil, nil } + return existingSession, nil } return nil, nil // No valid resume } @@ -425,12 +448,19 @@ func (m *mockSessionService) RemoveSession(session *Session) { } } +func (m *mockSessionService) MarkDisconnected(_ context.Context, session *Session) error { + if session.DbSessionID == 0 { + return fmt.Errorf("cannot mark session disconnected: not persisted (DbSessionID=0)") + } + return nil +} + func (m *mockSessionService) TerminateSession(_ context.Context, session *Session) error { m.RemoveSession(session) return nil } -func (m *mockSessionService) UpdateEncoding(_ context.Context, sessionID int64, encoding string) error { +func (m *mockSessionService) UpdateEncoding(_ context.Context, _ int64, sessionID int64, encoding string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/KC-Core/pkg/bssci/version_interop_test.go b/KC-Core/pkg/bssci/version_interop_test.go new file mode 100644 index 0000000..a143930 --- /dev/null +++ b/KC-Core/pkg/bssci/version_interop_test.go @@ -0,0 +1,355 @@ +package bssci + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// interopSCEui exercises exact outbound encoding: the high bit is set and the +// value exceeds float64 exact-integer range, so any lossy projection corrupts it. +const interopSCEui = uint64(0xFFFFFFFFFFFFFFFF) + +// interopConnectionService mirrors the production registry semantics for a +// base station registered under tenant 1 (any EUI accepted). +type interopConnectionService struct{} + +func (interopConnectionService) GetBaseStation(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { + return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Interop BS"}, nil +} + +func (interopConnectionService) GetBaseStationGlobal(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { + return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Interop BS"}, nil +} + +func (interopConnectionService) RegisterConnection(_ context.Context, _ *Session, _ *basestation.BaseStation, _ *basestation.ConnectionManager) error { + return nil +} + +// interopHarness drives a real Server through net.Pipe with framed wire +// traffic in the configured encoding. +type interopHarness struct { + t *testing.T + conn net.Conn + encoding string + server *Server + done chan struct{} +} + +func startInteropServer(t *testing.T, encoding string) *interopHarness { + t.Helper() + + log := logger.NewNop() + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := CreateTestServices(log, nil) + + server := NewTestServer(log, mockStorage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, interopConnectionService{}, broadcaster, + queueSerializer, auditLogger, tenantResolver) + server.config = &Config{ + ServiceCenterEUI: interopSCEui, + Vendor: "test-vendor", + Model: "test-model", + Name: "test-sc", + SoftwareVersion: "1.0.0", + MessageEncoding: encoding, + OperationAckTimeout: 2 * time.Second, + } + server.RegisterHandlers() + ctx, cancel := context.WithCancel(context.Background()) + server.ctx = ctx + server.cancel = cancel + t.Cleanup(cancel) + + client, srvSide := net.Pipe() + done := make(chan struct{}) + server.wg.Add(1) + go func() { + defer close(done) + server.handleConnection(srvSide) + }() + t.Cleanup(func() { + _ = client.Close() + <-done + }) + + return &interopHarness{t: t, conn: client, encoding: encoding, server: server, done: done} +} + +func (h *interopHarness) writeFrame(payload map[string]interface{}) { + h.t.Helper() + raw, err := encodeMessage(payload, h.encoding) + require.NoError(h.t, err) + + header := make([]byte, HeaderSize) + copy(header[:8], mioty.MIOTYFrameIdentifier[:]) + binary.LittleEndian.PutUint32(header[8:], uint32(len(raw))) + + require.NoError(h.t, h.conn.SetWriteDeadline(time.Now().Add(2*time.Second))) + _, err = h.conn.Write(append(header, raw...)) + require.NoError(h.t, err) +} + +// readFrame decodes the next frame; JSON numbers surface as json.Number so +// exact values can be asserted. +func (h *interopHarness) readFrame() map[string]interface{} { + h.t.Helper() + raw := h.readFrameRaw() + decoded, err := decodeMessage(raw, h.encoding) + require.NoError(h.t, err) + return decoded +} + +func (h *interopHarness) readFrameRaw() []byte { + h.t.Helper() + require.NoError(h.t, h.conn.SetReadDeadline(time.Now().Add(2*time.Second))) + header := make([]byte, HeaderSize) + _, err := io.ReadFull(h.conn, header) + require.NoError(h.t, err) + require.True(h.t, bytes.Equal(header[:8], mioty.MIOTYFrameIdentifier[:]), "frame identifier") + + payload := make([]byte, binary.LittleEndian.Uint32(header[8:])) + _, err = io.ReadFull(h.conn, payload) + require.NoError(h.t, err) + return payload +} + +// expectClosed asserts the server side closes the connection. +func (h *interopHarness) expectClosed() { + h.t.Helper() + // A closed pipe can already fail the deadline call - that is the + // expected outcome + if err := h.conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + require.ErrorIs(h.t, err, io.ErrClosedPipe) + return + } + buf := make([]byte, 1) + _, err := h.conn.Read(buf) + require.Error(h.t, err, "connection must be closed by the service center") + require.True(h.t, errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe), + "expected closed connection, got %v", err) +} + +func connectPayload(version string, bsEui interface{}) map[string]interface{} { + // snBsUuid is mandatory in con (§5.3.1); a JSON encoder emits it as a + // numeric array, msgpack as a binary - the numeric array decodes + // correctly under both + snBsUUID := make([]interface{}, 16) + for i := range snBsUUID { + snBsUUID[i] = int64(i + 1) + } + payload := map[string]interface{}{ + "command": mioty.CmdConnect, + "opId": int64(0), + "bsEui": bsEui, + "bidi": true, + "snBsUuid": snBsUUID, + } + if version != "" { + payload["version"] = version + } + return payload +} + +func frameCommand(frame map[string]interface{}) string { + cmd, _ := frame["command"].(string) + return cmd +} + +func frameUint64(t *testing.T, frame map[string]interface{}, key string) uint64 { + t.Helper() + v, err := coerceUint64(frame[key]) + require.NoError(t, err, "field %s must coerce exactly (got %T)", key, frame[key]) + return v +} + +// TestBSSCIVersionInterop_NegotiationMatrix drives the full connect version +// arbitration over real framed traffic (rev1 §4.2, §5.3.2): compatible +// requests receive the service center's selected version in conRsp and the +// session activates on conCmp. +func TestBSSCIVersionInterop_NegotiationMatrix(t *testing.T) { + compatible := []string{"1.0.0", "1.0.99", "1.1.0", "1.5.7"} + for _, encoding := range []string{EncodingJSON, EncodingMessagePack} { + for _, requested := range compatible { + t.Run(fmt.Sprintf("%s_%s", encoding, requested), func(t *testing.T) { + h := startInteropServer(t, encoding) + h.writeFrame(connectPayload(requested, uint64(0x70B3D59CD00009E6))) + + conRsp := h.readFrame() + require.Equal(t, mioty.CmdConnectResponse, frameCommand(conRsp)) + assert.Equal(t, mioty.MIOTYProtocolVersion, conRsp["version"], + "conRsp must carry the service center's selected version") + + // Base station agrees by completing the operation + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + + // An active session answers ping with pingRsp (BSSCI §5.4) + h.writeFrame(map[string]interface{}{"command": mioty.CmdPing, "opId": int64(1)}) + pingRsp := h.readFrame() + assert.Equal(t, mioty.CmdPingResponse, frameCommand(pingRsp)) + }) + } + } +} + +// TestBSSCIVersionInterop_IncompatibleMajor verifies con with a different +// major version follows error, errorAck, close (§4.1, §5.17). +func TestBSSCIVersionInterop_IncompatibleMajor(t *testing.T) { + for _, encoding := range []string{EncodingJSON, EncodingMessagePack} { + for _, requested := range []string{"0.9.0", "2.0.0"} { + t.Run(fmt.Sprintf("%s_%s", encoding, requested), func(t *testing.T) { + h := startInteropServer(t, encoding) + h.writeFrame(connectPayload(requested, uint64(0x70B3D59CD00009E6))) + + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame)) + code, err := coerceInt64(errFrame["code"]) + require.NoError(t, err) + assert.Equal(t, int64(POSIX_EPROTO), code) + + // The error acknowledgement completes the failed operation + h.writeFrame(map[string]interface{}{"command": mioty.CmdErrorAck, "opId": int64(0)}) + h.expectClosed() + }) + } + } +} + +// TestBSSCIVersionInterop_BaseStationRejectsOffer verifies the base station +// rejecting the offered version: con, conRsp, error, errorAck, close (§5.17). +func TestBSSCIVersionInterop_BaseStationRejectsOffer(t *testing.T) { + for _, encoding := range []string{EncodingJSON, EncodingMessagePack} { + t.Run(encoding, func(t *testing.T) { + h := startInteropServer(t, encoding) + h.writeFrame(connectPayload("1.1.0", uint64(0x70B3D59CD00009E6))) + + conRsp := h.readFrame() + require.Equal(t, mioty.CmdConnectResponse, frameCommand(conRsp)) + + // Base station cannot accept the offered version + h.writeFrame(map[string]interface{}{ + "command": mioty.CmdError, + "opId": int64(0), + "code": int64(POSIX_EPROTO), + "message": "unsupported version", + }) + + ack := h.readFrame() + assert.Equal(t, mioty.CmdErrorAck, frameCommand(ack)) + h.expectClosed() + }) + } +} + +// TestBSSCIVersionInterop_MissingAndMalformedVersion verifies the mandatory +// version field over the wire (rev1 §5.3.1). +func TestBSSCIVersionInterop_MissingAndMalformedVersion(t *testing.T) { + cases := map[string]string{"missing": "", "malformed": "1.0", "signed": "-1.0.0"} + for _, encoding := range []string{EncodingJSON, EncodingMessagePack} { + for name, version := range cases { + t.Run(fmt.Sprintf("%s_%s", encoding, name), func(t *testing.T) { + h := startInteropServer(t, encoding) + h.writeFrame(connectPayload(version, uint64(0x70B3D59CD00009E6))) + + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame)) + }) + } + } +} + +// TestBSSCIVersionInterop_UnknownFieldsIgnored verifies §2.4 forward +// compatibility: BSSCI 1.1 fields such as bsClass and subchan are dropped +// with a warning and the connect succeeds. +func TestBSSCIVersionInterop_UnknownFieldsIgnored(t *testing.T) { + for _, encoding := range []string{EncodingJSON, EncodingMessagePack} { + t.Run(encoding, func(t *testing.T) { + h := startInteropServer(t, encoding) + payload := connectPayload("1.1.0", uint64(0x70B3D59CD00009E6)) + payload["bsClass"] = int64(1) + payload["subchan"] = int64(3) + h.writeFrame(payload) + + conRsp := h.readFrame() + require.Equal(t, mioty.CmdConnectResponse, frameCommand(conRsp)) + assert.Equal(t, mioty.MIOTYProtocolVersion, conRsp["version"]) + }) + } +} + +// TestBSSCIVersionInterop_EUI64Matrix verifies the full unsigned EUI-64 range +// end to end over both encodings: inbound bsEui values above INT64_MAX are +// accepted and the outbound scEui survives exactly. +func TestBSSCIVersionInterop_EUI64Matrix(t *testing.T) { + euis := []uint64{ + 0x0000000000000001, + 0x7FFFFFFFFFFFFFFF, + 0x8000000000000000, + 0xCAFECAFECAFECAFE, + 0xFFFFFFFFFFFFFFFF, + } + for _, encoding := range []string{EncodingJSON, EncodingMessagePack} { + for _, eui := range euis { + t.Run(fmt.Sprintf("%s_%016X", encoding, eui), func(t *testing.T) { + h := startInteropServer(t, encoding) + + var bsEui interface{} = eui + if encoding == EncodingJSON { + // JSON wire input for the write helper: json.Marshal of + // uint64 emits the exact decimal digits + bsEui = json.Number(fmt.Sprintf("%d", eui)) + } + h.writeFrame(connectPayload("1.0.0", bsEui)) + + conRsp := h.readFrame() + require.Equal(t, mioty.CmdConnectResponse, frameCommand(conRsp), + "bsEui %016X must be accepted", eui) + + // Outbound scEui must be bit-exact in the emitted frame + assert.Equal(t, interopSCEui, frameUint64(t, conRsp, "scEui"), + "outbound scEui must survive encoding exactly") + + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + h.writeFrame(map[string]interface{}{"command": mioty.CmdPing, "opId": int64(1)}) + pingRsp := h.readFrame() + assert.Equal(t, mioty.CmdPingResponse, frameCommand(pingRsp)) + }) + } + } +} + +// TestBSSCIVersionInterop_OliverScenario replays the exact field report: +// an AVA base station (BSSCI 1.1, EUI CA-FE-CA-FE-CA-FE-CA-FE) connects over +// MessagePack, negotiates down to 1.0.0, and activates. +func TestBSSCIVersionInterop_OliverScenario(t *testing.T) { + h := startInteropServer(t, EncodingMessagePack) + + payload := connectPayload("1.1.0", uint64(0xCAFECAFECAFECAFE)) + payload["bsClass"] = int64(1) + payload["subchan"] = int64(3) + payload["vendor"] = "DIEHL Metering" + payload["model"] = "AVA" + h.writeFrame(payload) + + conRsp := h.readFrame() + require.Equal(t, mioty.CmdConnectResponse, frameCommand(conRsp), + "the 1.1 base station must receive conRsp, not error 71") + assert.Equal(t, mioty.MIOTYProtocolVersion, conRsp["version"]) + + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + h.writeFrame(map[string]interface{}{"command": mioty.CmdPing, "opId": int64(1)}) + pingRsp := h.readFrame() + assert.Equal(t, mioty.CmdPingResponse, frameCommand(pingRsp)) +} diff --git a/KC-Core/pkg/config/constants.go b/KC-Core/pkg/config/constants.go index 3404c89..1ac917f 100644 --- a/KC-Core/pkg/config/constants.go +++ b/KC-Core/pkg/config/constants.go @@ -368,6 +368,21 @@ const ( // DefaultProtocolSCModel is the Service Center model identifier. DefaultProtocolSCModel = "KiloCenter" + // DefaultProtocolSCEUI is the canonical Service Center EUI ("KC" ASCII prefix + unique ID) + // as a plain 16-hex string. + DefaultProtocolSCEUI = "4B43000000000001" + + // ConfigKeyProtocolSCEUI is the config file key for the Service Center EUI. + ConfigKeyProtocolSCEUI = "protocol.sc_eui" + + // EnvProtocolSCEUI is the environment variable that overrides protocol.sc_eui. + EnvProtocolSCEUI = "KILOCENTER_PROTOCOL_SC_EUI" + + // EnvLegacyServiceCenterEUI is the deprecated Service Center EUI environment variable. + // It accepts base-0 syntax (decimal or 0x-prefixed) and is consulted only when neither + // EnvProtocolSCEUI nor an explicit protocol.sc_eui file value is present. + EnvLegacyServiceCenterEUI = "SERVICE_CENTER_EUI" + // DefaultProtocolMaxRetransmissions is the max retry count for operations. DefaultProtocolMaxRetransmissions = 3 @@ -377,6 +392,9 @@ const ( // DefaultProtocolDuplicateWindow is the duplicate detection window in seconds. DefaultProtocolDuplicateWindow = 300 + // DefaultProtocolCertificatePollInterval is the base station certificate change poll interval. + DefaultProtocolCertificatePollInterval = 10 * time.Second + // DefaultProtocolPropBatchSize is the propagation batch size. DefaultProtocolPropBatchSize = 500 diff --git a/KC-Core/pkg/config/loader.go b/KC-Core/pkg/config/loader.go index d36d6ae..7dd83f4 100644 --- a/KC-Core/pkg/config/loader.go +++ b/KC-Core/pkg/config/loader.go @@ -4,8 +4,12 @@ package config import ( "fmt" + "os" + "strconv" "strings" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/mioty" + "github.com/Kiloiot/kilo-service-center/KC-DB/common/validation" "github.com/spf13/viper" ) @@ -68,6 +72,10 @@ func Load(configPath string) (*Config, error) { } } + if err := resolveServiceCenterEUI(v, &cfg); err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { return nil, err } @@ -75,6 +83,53 @@ func Load(configPath string) (*Config, error) { return &cfg, nil } +// resolveServiceCenterEUI resolves the Service Center EUI at load time with precedence: +// KILOCENTER_PROTOCOL_SC_EUI env var > explicit protocol.sc_eui file value > +// legacy SERVICE_CENTER_EUI env var (base-0 decimal/0x syntax) > centralized default. +// A malformed value at the highest consulted level fails the load; lower-priority sources +// are never used as a silent fallback for a malformed higher-priority value. +func resolveServiceCenterEUI(v *viper.Viper, cfg *Config) error { + setCanonical := func(value uint64, legacy bool) { + cfg.Protocol.SCEUI = mioty.FormatEUI64(value) + cfg.Protocol.SCEUIValue = value + cfg.Protocol.SCEUILegacyEnvUsed = legacy + } + + if raw, ok := os.LookupEnv(EnvProtocolSCEUI); ok && raw != "" { + value, err := validation.ParseEUI(raw) + if err != nil { + return fmt.Errorf(ErrSCEUIInvalidFmt, EnvProtocolSCEUI, err) + } + setCanonical(value, false) + return nil + } + + if v.InConfig(ConfigKeyProtocolSCEUI) && cfg.Protocol.SCEUI != "" { + value, err := validation.ParseEUI(cfg.Protocol.SCEUI) + if err != nil { + return fmt.Errorf(ErrSCEUIInvalidFmt, ConfigKeyProtocolSCEUI, err) + } + setCanonical(value, false) + return nil + } + + if raw, ok := os.LookupEnv(EnvLegacyServiceCenterEUI); ok && raw != "" { + value, err := strconv.ParseUint(raw, 0, 64) + if err != nil { + return fmt.Errorf(ErrSCEUIInvalidFmt, EnvLegacyServiceCenterEUI, err) + } + setCanonical(value, true) + return nil + } + + value, err := validation.ParseEUI(DefaultProtocolSCEUI) + if err != nil { + return fmt.Errorf(ErrSCEUIInvalidFmt, ConfigKeyProtocolSCEUI, err) + } + setCanonical(value, false) + return nil +} + // setDefaults sets ALL default configuration values. // Contains all defaults from internal/config/config.go plus bootstrap defaults. func setDefaults(v *viper.Viper) { @@ -112,6 +167,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("protocol.max_retransmissions", DefaultProtocolMaxRetransmissions) v.SetDefault("protocol.ack_timeout", DefaultProtocolAckTimeout) v.SetDefault("protocol.duplicate_window", DefaultProtocolDuplicateWindow) + v.SetDefault("protocol.bsci_certificate_poll_interval", DefaultProtocolCertificatePollInterval) // Federation relay defaults v.SetDefault("protocol.federation.enabled", DefaultFederationEnabled) diff --git a/KC-Core/pkg/config/loader_test.go b/KC-Core/pkg/config/loader_test.go index f10c64c..14a3a5b 100644 --- a/KC-Core/pkg/config/loader_test.go +++ b/KC-Core/pkg/config/loader_test.go @@ -89,6 +89,160 @@ registry_provider: } } +// writeSCEUIConfig writes a minimal config file, optionally including a protocol.sc_eui value. +func writeSCEUIConfig(t *testing.T, scEUIYAML string) string { + t.Helper() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + yaml := "general:\n server_name: \"test\"\n" + if scEUIYAML != "" { + yaml += "protocol:\n sc_eui: \"" + scEUIYAML + "\"\n" + } + if err := os.WriteFile(cfgPath, []byte(yaml), 0644); err != nil { + t.Fatalf("write temp config: %v", err) + } + return cfgPath +} + +// clearSCEUIEnv unsets both Service Center EUI environment variables for the test. +func clearSCEUIEnv(t *testing.T) { + t.Helper() + t.Setenv(EnvProtocolSCEUI, "") + t.Setenv(EnvLegacyServiceCenterEUI, "") +} + +func TestLoad_SCEUIDefault(t *testing.T) { + clearSCEUIEnv(t) + cfg, err := Load(writeSCEUIConfig(t, "")) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.SCEUI != DefaultProtocolSCEUI { + t.Errorf("SCEUI = %q, want %q", cfg.Protocol.SCEUI, DefaultProtocolSCEUI) + } + if cfg.Protocol.SCEUIValue != 0x4B43000000000001 { + t.Errorf("SCEUIValue = %#016x, want 0x4B43000000000001", cfg.Protocol.SCEUIValue) + } + if cfg.Protocol.SCEUILegacyEnvUsed { + t.Error("SCEUILegacyEnvUsed = true, want false for default") + } +} + +func TestLoad_SCEUIFromFile(t *testing.T) { + clearSCEUIEnv(t) + cfg, err := Load(writeSCEUIConfig(t, "CA-FE-CA-FE-CA-FE-CA-FE")) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.SCEUIValue != 0xCAFECAFECAFECAFE { + t.Errorf("SCEUIValue = %#016x, want 0xCAFECAFECAFECAFE", cfg.Protocol.SCEUIValue) + } + if cfg.Protocol.SCEUI != "CAFECAFECAFECAFE" { + t.Errorf("SCEUI = %q, want canonical %q", cfg.Protocol.SCEUI, "CAFECAFECAFECAFE") + } +} + +func TestLoad_SCEUIModernEnvWinsOverFile(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvProtocolSCEUI, "0102030405060708") + cfg, err := Load(writeSCEUIConfig(t, "CAFECAFECAFECAFE")) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.SCEUIValue != 0x0102030405060708 { + t.Errorf("SCEUIValue = %#016x, want 0x0102030405060708", cfg.Protocol.SCEUIValue) + } +} + +func TestLoad_SCEUILegacyEnvDecimal(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvLegacyServiceCenterEUI, "1234567890") + cfg, err := Load(writeSCEUIConfig(t, "")) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.SCEUIValue != 1234567890 { + t.Errorf("SCEUIValue = %d, want 1234567890", cfg.Protocol.SCEUIValue) + } + if cfg.Protocol.SCEUI != "00000000499602D2" { + t.Errorf("SCEUI = %q, want canonical %q", cfg.Protocol.SCEUI, "00000000499602D2") + } + if !cfg.Protocol.SCEUILegacyEnvUsed { + t.Error("SCEUILegacyEnvUsed = false, want true") + } +} + +func TestLoad_SCEUILegacyEnvHexHighBit(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvLegacyServiceCenterEUI, "0xCAFECAFECAFECAFE") + cfg, err := Load(writeSCEUIConfig(t, "")) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.SCEUIValue != 0xCAFECAFECAFECAFE { + t.Errorf("SCEUIValue = %#016x, want 0xCAFECAFECAFECAFE", cfg.Protocol.SCEUIValue) + } + if !cfg.Protocol.SCEUILegacyEnvUsed { + t.Error("SCEUILegacyEnvUsed = false, want true") + } +} + +func TestLoad_SCEUIMalformedModernEnvFails(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvProtocolSCEUI, "not-an-eui") + t.Setenv(EnvLegacyServiceCenterEUI, "0x4B43000000000001") + if _, err := Load(writeSCEUIConfig(t, "CAFECAFECAFECAFE")); err == nil { + t.Fatal("expected load failure for malformed modern env value, got nil") + } +} + +func TestLoad_SCEUIMalformedFileFails(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvLegacyServiceCenterEUI, "0x4B43000000000001") + if _, err := Load(writeSCEUIConfig(t, "ZZZZ")); err == nil { + t.Fatal("expected load failure for malformed file value, got nil") + } +} + +func TestLoad_SCEUIMalformedLegacyFails(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvLegacyServiceCenterEUI, "banana") + if _, err := Load(writeSCEUIConfig(t, "")); err == nil { + t.Fatal("expected load failure for malformed legacy env value, got nil") + } +} + +func TestLoad_SCEUILegacyIgnoredWhenModernEnvSet(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvProtocolSCEUI, "CAFECAFECAFECAFE") + t.Setenv(EnvLegacyServiceCenterEUI, "banana") + cfg, err := Load(writeSCEUIConfig(t, "")) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.SCEUIValue != 0xCAFECAFECAFECAFE { + t.Errorf("SCEUIValue = %#016x, want 0xCAFECAFECAFECAFE", cfg.Protocol.SCEUIValue) + } + if cfg.Protocol.SCEUILegacyEnvUsed { + t.Error("SCEUILegacyEnvUsed = true, want false when modern env supplies the value") + } +} + +func TestLoad_SCEUILegacyIgnoredWhenFileSet(t *testing.T) { + clearSCEUIEnv(t) + t.Setenv(EnvLegacyServiceCenterEUI, "1234567890") + cfg, err := Load(writeSCEUIConfig(t, "0102030405060708")) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Protocol.SCEUIValue != 0x0102030405060708 { + t.Errorf("SCEUIValue = %#016x, want 0x0102030405060708", cfg.Protocol.SCEUIValue) + } + if cfg.Protocol.SCEUILegacyEnvUsed { + t.Error("SCEUILegacyEnvUsed = true, want false when file supplies the value") + } +} + func TestInternalTrustStartupFailsWithGRPCWeb(t *testing.T) { cfg := &Config{ General: GeneralConfig{ServerName: "test"}, diff --git a/KC-Core/pkg/config/messages.go b/KC-Core/pkg/config/messages.go index 5c305cf..6f00569 100644 --- a/KC-Core/pkg/config/messages.go +++ b/KC-Core/pkg/config/messages.go @@ -7,6 +7,13 @@ const ( // ErrConfigUnmarshalFmt formats config unmarshal errors. ErrConfigUnmarshalFmt = "error unmarshaling config: %w" + // ErrSCEUIInvalidFmt formats Service Center EUI parse failures with the offending source. + ErrSCEUIInvalidFmt = "invalid Service Center EUI from %s: %w" + + // LogDeprecatedServiceCenterEUIEnv warns that the legacy SERVICE_CENTER_EUI variable supplied + // the Service Center EUI. Emitted by the consumer because config loading has no logger. + LogDeprecatedServiceCenterEUIEnv = "SERVICE_CENTER_EUI is deprecated; set KILOCENTER_PROTOCOL_SC_EUI or protocol.sc_eui instead" + // ErrServerNameRequired indicates server_name is missing. ErrServerNameRequired = "server name is required" // ErrUnsupportedStorageTypeFmt formats unsupported storage type errors. diff --git a/KC-Core/pkg/config/protocol.go b/KC-Core/pkg/config/protocol.go index be7a03b..7834a20 100644 --- a/KC-Core/pkg/config/protocol.go +++ b/KC-Core/pkg/config/protocol.go @@ -31,6 +31,9 @@ type ProtocolConfig struct { DuplicateWindow int `mapstructure:"duplicate_window"` // seconds MessageEncoding string `mapstructure:"message_encoding"` // json, msgpack + // BSCICertificatePollInterval is the base station certificate change poll interval + BSCICertificatePollInterval time.Duration `mapstructure:"bsci_certificate_poll_interval"` + // Automatic Propagation Reconciliation Propagation PropagationConfig `mapstructure:"propagation"` @@ -42,6 +45,16 @@ type ProtocolConfig struct { // Service Center Identity - exposed via GetReleaseInfo SCVendor string `mapstructure:"sc_vendor"` // Service Center vendor name (e.g., "Kilo") SCModel string `mapstructure:"sc_model"` // Service Center model (e.g., "KiloCenter") + + // SCEUI is the Service Center EUI as a canonical 16-hex string. Load resolves it with + // precedence: KILOCENTER_PROTOCOL_SC_EUI env var > explicit file value > + // legacy SERVICE_CENTER_EUI env var > centralized default. + SCEUI string `mapstructure:"sc_eui"` + // SCEUIValue is the numeric form of SCEUI, derived during Load so consumers never re-parse. + SCEUIValue uint64 `mapstructure:"-" yaml:"-"` + // SCEUILegacyEnvUsed records that the deprecated SERVICE_CENTER_EUI variable supplied the + // EUI; the first consumer emits the deprecation warning since Load has no logger. + SCEUILegacyEnvUsed bool `mapstructure:"-" yaml:"-"` } // PropagationConfig contains automatic endpoint propagation settings diff --git a/KC-Core/pkg/config/service_center.go b/KC-Core/pkg/config/service_center.go index 967bc73..7a39d83 100644 --- a/KC-Core/pkg/config/service_center.go +++ b/KC-Core/pkg/config/service_center.go @@ -28,6 +28,18 @@ func ValidateServiceCenterConfig(cfg *ProtocolConfig) error { return errors.New("protocol.bsci_tls.enabled must be true (BSSCI requires mutual TLS)") } + if cfg.AckTimeout <= 0 { + return fmt.Errorf("protocol.ack_timeout must be positive milliseconds, got %d", cfg.AckTimeout) + } + + if cfg.DuplicateWindow <= 0 { + return fmt.Errorf("protocol.duplicate_window must be positive seconds, got %d", cfg.DuplicateWindow) + } + + if cfg.BSCICertificatePollInterval <= 0 { + return fmt.Errorf("protocol.bsci_certificate_poll_interval must be a positive duration, got %s", cfg.BSCICertificatePollInterval) + } + return nil } diff --git a/KC-Core/pkg/config/service_center_test.go b/KC-Core/pkg/config/service_center_test.go index 7a0c66d..0a3389f 100644 --- a/KC-Core/pkg/config/service_center_test.go +++ b/KC-Core/pkg/config/service_center_test.go @@ -336,9 +336,12 @@ func TestValidateServiceCenterConfig_TLSDisabled(t *testing.T) { func TestValidateServiceCenterConfig_Valid(t *testing.T) { cfg := &ProtocolConfig{ - BSCIHost: "bssci.mioty.local", - BSCIPort: 5000, - BSCITLS: TLSConfig{Enabled: true}, + BSCIHost: "bssci.mioty.local", + BSCIPort: 5000, + BSCITLS: TLSConfig{Enabled: true}, + AckTimeout: DefaultProtocolAckTimeout, + DuplicateWindow: DefaultProtocolDuplicateWindow, + BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, } err := ValidateServiceCenterConfig(cfg) @@ -347,6 +350,37 @@ func TestValidateServiceCenterConfig_Valid(t *testing.T) { } } +func TestValidateServiceCenterConfig_TimingBounds(t *testing.T) { + base := func() *ProtocolConfig { + return &ProtocolConfig{ + BSCIHost: "bssci.mioty.local", + BSCIPort: 5000, + BSCITLS: TLSConfig{Enabled: true}, + AckTimeout: DefaultProtocolAckTimeout, + DuplicateWindow: DefaultProtocolDuplicateWindow, + BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, + } + } + + zeroAck := base() + zeroAck.AckTimeout = 0 + if err := ValidateServiceCenterConfig(zeroAck); err == nil { + t.Error("zero ack_timeout must fail validation") + } + + negativeWindow := base() + negativeWindow.DuplicateWindow = -1 + if err := ValidateServiceCenterConfig(negativeWindow); err == nil { + t.Error("negative duplicate_window must fail validation") + } + + zeroPoll := base() + zeroPoll.BSCICertificatePollInterval = 0 + if err := ValidateServiceCenterConfig(zeroPoll); err == nil { + t.Error("zero bsci_certificate_poll_interval must fail validation") + } +} + // ============================================================================= // GetServiceCenterURL Tests // ============================================================================= diff --git a/KC-Core/pkg/grpc/errors_catalog.go b/KC-Core/pkg/grpc/errors_catalog.go index e9bd43f..ce9bef8 100644 --- a/KC-Core/pkg/grpc/errors_catalog.go +++ b/KC-Core/pkg/grpc/errors_catalog.go @@ -361,11 +361,13 @@ const ( ErrTokenGetBaseStationAvailabilityFailed = "KC-GRPC-ERR-09F" ErrTokenGetBaseStationMessagesReceivedFailed = "KC-GRPC-ERR-0A0" ErrTokenInvalidMetricsRequest = "KC-GRPC-ERR-0A1" - ErrTokenTenantAccessDenied = "KC-GRPC-ERR-09A" - ErrTokenBaseStationEUIExists = "KC-GRPC-ERR-09B" - ErrTokenUpdateBaseStationEUIFailed = "KC-GRPC-ERR-09C" - ErrTokenNewBaseStationEUIRequired = "KC-GRPC-ERR-09D" - ErrTokenLatLonPairRequired = "KC-GRPC-ERR-09E" + // Conflicting bs_eui and bs_eui_hex values in the same request. + ErrTokenBaseStationEUIMismatch = "KC-GRPC-ERR-0A2" + ErrTokenTenantAccessDenied = "KC-GRPC-ERR-09A" + ErrTokenBaseStationEUIExists = "KC-GRPC-ERR-09B" + ErrTokenUpdateBaseStationEUIFailed = "KC-GRPC-ERR-09C" + ErrTokenNewBaseStationEUIRequired = "KC-GRPC-ERR-09D" + ErrTokenLatLonPairRequired = "KC-GRPC-ERR-09E" // Endpoint errors (KC-GRPC-ERR-101 to KC-GRPC-ERR-110) ErrTokenEndpointNotFound = "KC-GRPC-ERR-101" @@ -1818,6 +1820,11 @@ var errorCatalog = map[string]ErrorDefinition{ Message: "invalid base station metrics request", Code: codes.InvalidArgument, }, + ErrTokenBaseStationEUIMismatch: { + Token: ErrTokenBaseStationEUIMismatch, + Message: "bs_eui and bs_eui_hex refer to different base stations", + Code: codes.InvalidArgument, + }, ErrTokenTenantAccessDenied: { Token: ErrTokenTenantAccessDenied, Message: "cannot access resource for different tenant", diff --git a/KC-Core/pkg/grpc/interceptors/auth.go b/KC-Core/pkg/grpc/interceptors/auth.go index 38dad09..60de220 100644 --- a/KC-Core/pkg/grpc/interceptors/auth.go +++ b/KC-Core/pkg/grpc/interceptors/auth.go @@ -24,6 +24,12 @@ import ( "google.golang.org/grpc/status" ) +// Audit detail keys shared by interceptor deny events. +const ( + auditKeyMethod = "method" + auditKeyReason = "reason" +) + // AuthInterceptor provides JWT and API key bearer authentication for gRPC. type AuthInterceptor struct { enabled bool @@ -408,8 +414,8 @@ func (ai *AuthInterceptor) emitSecurityEvent(ctx context.Context, method, eventT tenantID = tid } details, _ := json.Marshal(map[string]interface{}{ - "method": method, - "reason": reason, + auditKeyMethod: method, + auditKeyReason: reason, }) _ = ai.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), diff --git a/KC-Core/pkg/grpc/interceptors/authorization.go b/KC-Core/pkg/grpc/interceptors/authorization.go index ac06d2f..b7090aa 100644 --- a/KC-Core/pkg/grpc/interceptors/authorization.go +++ b/KC-Core/pkg/grpc/interceptors/authorization.go @@ -163,8 +163,8 @@ func (ai *AuthorizationInterceptor) emitPermissionDenied(ctx context.Context, me tenantID = tid } details, _ := json.Marshal(map[string]interface{}{ - "method": method, - "reason": reason, + auditKeyMethod: method, + auditKeyReason: reason, }) _ = ai.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), diff --git a/KC-Core/pkg/grpc/interceptors/internal_trust.go b/KC-Core/pkg/grpc/interceptors/internal_trust.go index 3e71d2c..450e3a3 100644 --- a/KC-Core/pkg/grpc/interceptors/internal_trust.go +++ b/KC-Core/pkg/grpc/interceptors/internal_trust.go @@ -188,8 +188,8 @@ func (it *InternalTrustInterceptor) emitSecurityEvent(ctx context.Context, metho tenantID = tid } details, _ := json.Marshal(map[string]interface{}{ - "method": method, - "reason": reason, + auditKeyMethod: method, + auditKeyReason: reason, }) _ = it.eventWriter.CreateEvent(ctx, &models.SystemEvent{ TenantID: strconv.FormatInt(tenantID, 10), diff --git a/KC-Core/pkg/logger/logger.go b/KC-Core/pkg/logger/logger.go index 5d9f37e..44faf9f 100644 --- a/KC-Core/pkg/logger/logger.go +++ b/KC-Core/pkg/logger/logger.go @@ -63,6 +63,9 @@ var ( // included in log entries without explicit field passing. // // Returns a map with available context fields. Missing fields are omitted (not set to empty/zero values). +// errorFieldKey is the structured log field name for error values. +const errorFieldKey = "error" + func extractContextFields(ctx context.Context) map[string]interface{} { fields := make(map[string]interface{}) @@ -351,7 +354,7 @@ func parseLevel(level string) Level { return InfoLevel case "warn", "warning": return WarnLevel - case "error": + case errorFieldKey: return ErrorLevel case "fatal": return FatalLevel @@ -413,7 +416,7 @@ func toMap(fields ...interface{}) map[string]interface{} { i += 2 continue } - val := fields[i+1] + val := fields[i+1] //nolint:gosec // G602: bounds guarded above (i+1 >= len(fields) breaks) if e, ok := val.(error); ok { m[key] = e.Error() } else { @@ -442,9 +445,9 @@ func Bool(key string, value bool) Field { // Err creates an error field func Err(err error) Field { if err == nil { - return Field{Key: "error", Value: nil} + return Field{Key: errorFieldKey, Value: nil} } - return Field{Key: "error", Value: err.Error()} + return Field{Key: errorFieldKey, Value: err.Error()} } // Any creates a field with any value diff --git a/KC-Core/pkg/management/bssci_manager.go b/KC-Core/pkg/management/bssci_manager.go index 4a537e5..5969a4a 100644 --- a/KC-Core/pkg/management/bssci_manager.go +++ b/KC-Core/pkg/management/bssci_manager.go @@ -12,6 +12,12 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" ) +// JSON response field keys for management endpoints. +const ( + responseKeySuccess = "success" + responseKeyErrors = "errors" +) + // BSSCIManager manages BSSCI operations for API access type BSSCIManager struct { server *bssci.Server @@ -159,7 +165,7 @@ func (m *BSSCIManager) handleAttachPropagate(w http.ResponseWriter, r *http.Requ } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]bool{responseKeySuccess: true}); err != nil { m.logger.Error(bssci.ResolveErrorMessage(bssci.ErrMgmtJSONEncodeFailed), "error", err) http.Error(w, bssci.ResolveErrorMessage(bssci.ErrMgmtJSONEncodeFailed), http.StatusInternalServerError) return @@ -244,16 +250,16 @@ func (m *BSSCIManager) handleAttachPropagateAll(w http.ResponseWriter, r *http.R "error", err) } response = map[string]interface{}{ - "success": false, - "errors": errorMessages, + responseKeySuccess: false, + responseKeyErrors: errorMessages, } } else { m.logger.Info("Attach propagate sent successfully to all sessions", "endpointEUI", req.EndpointEUI, "sessionCount", len(m.server.GetConnectedSessions())) response = map[string]interface{}{ - "success": true, - "errors": []string{}, + responseKeySuccess: true, + responseKeyErrors: []string{}, } } @@ -352,7 +358,7 @@ func (m *BSSCIManager) handleDetachPropagate(w http.ResponseWriter, r *http.Requ } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]bool{responseKeySuccess: true}); err != nil { m.logger.Error(bssci.ResolveErrorMessage(bssci.ErrMgmtJSONEncodeFailed), "error", err) http.Error(w, bssci.ResolveErrorMessage(bssci.ErrMgmtJSONEncodeFailed), http.StatusInternalServerError) return @@ -386,16 +392,16 @@ func (m *BSSCIManager) handleDetachPropagateAll(w http.ResponseWriter, r *http.R "error", err) } response = map[string]interface{}{ - "success": false, - "errors": errorMessages, + responseKeySuccess: false, + responseKeyErrors: errorMessages, } } else { m.logger.Info("Detach propagate sent successfully to all sessions", "endpointEUI", req.EndpointEUI, "sessionCount", len(m.server.GetConnectedSessions())) response = map[string]interface{}{ - "success": true, - "errors": []string{}, + responseKeySuccess: true, + responseKeyErrors: []string{}, } } diff --git a/KC-Core/pkg/mioty/format.go b/KC-Core/pkg/mioty/format.go index 035babf..ba12fcc 100644 --- a/KC-Core/pkg/mioty/format.go +++ b/KC-Core/pkg/mioty/format.go @@ -6,6 +6,7 @@ package mioty import ( "encoding/hex" "fmt" + "strings" "time" ) @@ -15,6 +16,20 @@ func FormatEUI64(eui uint64) string { return fmt.Sprintf("%016X", eui) } +// EUIDashSeparator joins EUI64 byte pairs in the dashed display format. +const EUIDashSeparator = "-" + +// FormatEUI64Dashed returns the uppercase dashed representation of an EUI64 +// (XX-XX-XX-XX-XX-XX-XX-XX), used for certificate common names. +func FormatEUI64Dashed(eui uint64) string { + plain := FormatEUI64(eui) + pairs := make([]string, 0, len(plain)/2) + for i := 0; i < len(plain); i += 2 { + pairs = append(pairs, plain[i:i+2]) + } + return strings.Join(pairs, EUIDashSeparator) +} + // Timestamp formatting constants. // These MUST NOT be defined inline in adapter files. const ( diff --git a/KC-Core/pkg/mioty/format_test.go b/KC-Core/pkg/mioty/format_test.go new file mode 100644 index 0000000..289bb66 --- /dev/null +++ b/KC-Core/pkg/mioty/format_test.go @@ -0,0 +1,38 @@ +package mioty + +import ( + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-DB/common/validation" +) + +func TestFormatEUI64Dashed(t *testing.T) { + tests := []struct { + name string + eui uint64 + want string + }{ + {name: "high-bit EUI", eui: 0xCAFECAFECAFECAFE, want: "CA-FE-CA-FE-CA-FE-CA-FE"}, + {name: "zero", eui: 0, want: "00-00-00-00-00-00-00-00"}, + {name: "default service center EUI", eui: 0x4B43000000000001, want: "4B-43-00-00-00-00-00-01"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatEUI64Dashed(tt.eui); got != tt.want { + t.Errorf("FormatEUI64Dashed(%#016x) = %q, want %q", tt.eui, got, tt.want) + } + }) + } +} + +func TestFormatEUI64Dashed_RoundTripWithParseEUI(t *testing.T) { + const eui = uint64(0xCAFECAFECAFECAFE) + dashed := FormatEUI64Dashed(eui) + parsed, err := validation.ParseEUI(dashed) + if err != nil { + t.Fatalf("ParseEUI(%q) error: %v", dashed, err) + } + if parsed != eui { + t.Errorf("round-trip = %#016x, want %#016x", parsed, eui) + } +} diff --git a/KC-DB/migrations/000119_add_user_profile_fields.down.sql b/KC-DB/migrations/000119_add_user_profile_fields.down.sql index 40a6c87..ddea861 100644 --- a/KC-DB/migrations/000119_add_user_profile_fields.down.sql +++ b/KC-DB/migrations/000119_add_user_profile_fields.down.sql @@ -1,6 +1,10 @@ +-- The public.users compatibility view depends on the profile columns, so it +-- must be dropped before the columns and recreated afterwards. +DROP VIEW IF EXISTS public.users; + ALTER TABLE identity.users DROP COLUMN IF EXISTS first_name, DROP COLUMN IF EXISTS last_name, DROP COLUMN IF EXISTS company_name; -CREATE OR REPLACE VIEW public.users AS SELECT * FROM identity.users; +CREATE VIEW public.users AS SELECT * FROM identity.users; diff --git a/KC-DB/migrations/000139_restore_message_archival.down.sql b/KC-DB/migrations/000139_restore_message_archival.down.sql new file mode 100644 index 0000000..db4d29e --- /dev/null +++ b/KC-DB/migrations/000139_restore_message_archival.down.sql @@ -0,0 +1,47 @@ +-- Revert to the pre-000139 state: messages without archival columns and the +-- pre-000047 messages_archive layout inherited from migrations 001/014. + +DROP TRIGGER IF EXISTS set_messages_archive_timestamp ON messages_archive; +DROP TABLE IF EXISTS messages_archive; + +DROP INDEX IF EXISTS idx_messages_archived; +ALTER TABLE messages DROP COLUMN IF EXISTS archived; +ALTER TABLE messages DROP COLUMN IF EXISTS archived_at; + +-- Recreate the legacy archive layout (001-era messages columns plus the +-- archived/archived_at columns that migration 014 relied on). +CREATE TABLE messages_archive ( + id BIGINT NOT NULL, + ep_eui BYTEA NOT NULL CHECK (length(ep_eui) = 8), + bs_eui BYTEA NOT NULL CHECK (length(bs_eui) = 8), + tenant_id BIGINT NOT NULL, + payload BYTEA NOT NULL, + frame_count INTEGER NOT NULL, + rssi REAL NOT NULL, + snr REAL NOT NULL, + eq_snr REAL NOT NULL, + frequency DOUBLE PRECISION NOT NULL, + uplink_mode VARCHAR(10) DEFAULT 'standard', + packet_counter INTEGER, + dl_open BOOLEAN DEFAULT false, + res_exp BOOLEAN DEFAULT false, + dl_ack BOOLEAN DEFAULT false, + is_roaming BOOLEAN DEFAULT false, + home_network_id VARCHAR(50), + roaming_agreement_id BIGINT, + received_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + archived BOOLEAN DEFAULT false, + archived_at TIMESTAMP WITH TIME ZONE, + PRIMARY KEY (id, received_at) +); + +CREATE INDEX IF NOT EXISTS idx_messages_archive_ep_eui ON messages_archive(ep_eui); +CREATE INDEX IF NOT EXISTS idx_messages_archive_tenant_id ON messages_archive(tenant_id); +CREATE INDEX IF NOT EXISTS idx_messages_archive_received_at ON messages_archive(received_at); +CREATE INDEX IF NOT EXISTS idx_messages_archive_archived_at ON messages_archive(archived_at); + +CREATE TRIGGER set_messages_archive_timestamp + BEFORE INSERT ON messages_archive + FOR EACH ROW + EXECUTE FUNCTION set_archived_at(); diff --git a/KC-DB/migrations/000139_restore_message_archival.up.sql b/KC-DB/migrations/000139_restore_message_archival.up.sql new file mode 100644 index 0000000..a3d14f2 --- /dev/null +++ b/KC-DB/migrations/000139_restore_message_archival.up.sql @@ -0,0 +1,36 @@ +-- Migration 000139: restore message archival columns and rebuild messages_archive. +-- Migration 000047 rebuilt the messages table without the archived/archived_at +-- columns while messages_archive kept the pre-000047 column layout, so the +-- archival copy from messages into messages_archive failed at runtime. + +-- Restore archival tracking columns on the live table. +ALTER TABLE messages ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE messages ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE; + +-- Partial index used by the archival sweep and the purge of archived rows. +CREATE INDEX IF NOT EXISTS idx_messages_archived ON messages(archived, received_at) WHERE archived = false; + +-- Rebuild the archive table so its column layout matches the live table. +DROP TABLE IF EXISTS messages_archive; +CREATE TABLE messages_archive ( + LIKE messages INCLUDING DEFAULTS INCLUDING CONSTRAINTS +); + +-- Primary key keeps the copy-if-absent archival insert deduplicated. +ALTER TABLE messages_archive ADD PRIMARY KEY (id); + +-- Query indexes for the archive table (names follow migration 014). +CREATE INDEX idx_messages_archive_ep_eui ON messages_archive(ep_eui); +CREATE INDEX idx_messages_archive_tenant_id ON messages_archive(tenant_id); +CREATE INDEX idx_messages_archive_received_at ON messages_archive(received_at); +CREATE INDEX idx_messages_archive_archived_at ON messages_archive(archived_at); + +-- Stamp archived_at on insert (set_archived_at() is created by migration 014). +CREATE TRIGGER set_messages_archive_timestamp + BEFORE INSERT ON messages_archive + FOR EACH ROW + EXECUTE FUNCTION set_archived_at(); + +COMMENT ON TABLE messages_archive IS 'Archive table for old messages (layout mirrors messages)'; +COMMENT ON COLUMN messages.archived IS 'True once the row has been copied to messages_archive'; +COMMENT ON COLUMN messages.archived_at IS 'Timestamp when the row was copied to messages_archive'; diff --git a/KC-DB/storage/interfaces/basestation_session_repository.go b/KC-DB/storage/interfaces/basestation_session_repository.go index 37498c8..bdf44e5 100644 --- a/KC-DB/storage/interfaces/basestation_session_repository.go +++ b/KC-DB/storage/interfaces/basestation_session_repository.go @@ -36,7 +36,7 @@ type BaseStationSessionRepository interface { // UpdateEncoding updates the message encoding for a session (json or msgpack) // This is called when encoding is negotiated on first message per BSSCI Section 1 - UpdateEncoding(ctx context.Context, sessionID int64, encoding string) error + UpdateEncoding(ctx context.Context, tenantID, sessionID int64, encoding string) error // TerminateSession marks a session as terminated TerminateSession(ctx context.Context, tenantID, sessionID int64) error @@ -56,9 +56,15 @@ type BaseStationSessionRepository interface { // CleanupExpiredSessions removes old terminated sessions (housekeeping) CleanupExpiredSessions(ctx context.Context, olderThan int64) (int64, error) // olderThan in hours - // UpdateCountersAndTimestamp updates operation counters by session UUID - // NOTE: sessionUUID is [16]byte to match database CHECK constraint (length = 16) - UpdateCountersAndTimestamp(ctx context.Context, sessionUUID [16]byte, bsOpId, scOpId int64) error + // MarkDisconnected marks a session disconnected and resumable, but only + // while the stored connection ID still matches - a newer connection that + // already replaced this one is left untouched (zero rows is not an error) + MarkDisconnected(ctx context.Context, tenantID, sessionID int64, connectionID string, endedAt time.Time) error + + // FindResumableSession finds the resumable session for a base station: + // scoped by tenant, base station EUI, and snBsUuid, with + // status=disconnected and can_resume=true (BSSCI §5.3.1) + FindResumableSession(ctx context.Context, tenantID int64, bsEUI []byte, snBsUUID [16]byte) (*models.BaseStationSession, error) } // SessionStatistics represents aggregated session statistics diff --git a/KC-DB/storage/mioty/types.go b/KC-DB/storage/mioty/types.go index e29e8b3..410da38 100644 --- a/KC-DB/storage/mioty/types.go +++ b/KC-DB/storage/mioty/types.go @@ -90,6 +90,17 @@ type BaseMessage struct { OpId int64 `json:"opId" msgpack:"opId"` // Operation ID (signed: positive for BS, negative for SC) } +// EnvelopeCommand returns the wire command mnemonic, exposing the message +// envelope without serialization round-trips. +func (m BaseMessage) EnvelopeCommand() string { + return m.CommandType +} + +// EnvelopeOpID returns the operation ID of the message envelope. +func (m BaseMessage) EnvelopeOpID() int64 { + return m.OpId +} + // Type definitions per MIOTY BSSCI v1.0.0 specification type ( // EUI64 represents an 8-byte Extended Unique Identifier diff --git a/KC-DB/storage/models/basestation_session.go b/KC-DB/storage/models/basestation_session.go index b2c9023..4bfde39 100644 --- a/KC-DB/storage/models/basestation_session.go +++ b/KC-DB/storage/models/basestation_session.go @@ -9,12 +9,15 @@ import ( // BaseStationSessionStatus represents the status of a Base Station session type BaseStationSessionStatus string -// BaseStationSessionStatus constants for session lifecycle states. +// BaseStationSessionStatus constants for session lifecycle states. Fresh and +// successfully resumed connections are both "active" (IsResumed on the live +// session is the audit indicator); "disconnected" marks a resumable session +// after unexpected connection loss; "terminated" sessions never resume. const ( // SessionStatusActive indicates an active session. SessionStatusActive BaseStationSessionStatus = "active" - // SessionStatusResumed indicates a resumed session. - SessionStatusResumed BaseStationSessionStatus = "resumed" + // SessionStatusDisconnected indicates a session that lost its connection and may be resumed. + SessionStatusDisconnected BaseStationSessionStatus = "disconnected" // SessionStatusTerminated indicates a terminated session. SessionStatusTerminated BaseStationSessionStatus = "terminated" ) @@ -95,6 +98,8 @@ type BaseStationSessionUpdateRequest struct { Status *BaseStationSessionStatus `json:"status,omitempty"` LastPingAt *time.Time `json:"last_ping_at,omitempty"` EndedAt *time.Time `json:"ended_at,omitempty"` + CanResume *bool `json:"can_resume,omitempty"` + ClearEndedAt bool `json:"clear_ended_at,omitempty"` // Sets ended_at to NULL; mutually exclusive with EndedAt ConnectionId *string `json:"connection_id,omitempty"` RemoteAddr *string `json:"remote_addr,omitempty"` Encoding *string `json:"encoding,omitempty" validate:"omitempty,oneof=json msgpack"` // Message encoding @@ -104,12 +109,12 @@ type BaseStationSessionUpdateRequest struct { // IsActive returns true if the session is currently active func (s *BaseStationSession) IsActive() bool { - return s.Status == SessionStatusActive || s.Status == SessionStatusResumed + return s.Status == SessionStatusActive } // CanResumeSession returns true if this session can be resumed after disconnection func (s *BaseStationSession) CanResumeSession() bool { - return s.CanResume && (s.Status == SessionStatusActive || s.Status == SessionStatusResumed) + return s.CanResume && s.Status == SessionStatusDisconnected } // GetNextScOpId returns the next Service Center operation ID for this session diff --git a/KC-DB/storage/postgres/archival.go b/KC-DB/storage/postgres/archival.go index 045c50e..128c899 100644 --- a/KC-DB/storage/postgres/archival.go +++ b/KC-DB/storage/postgres/archival.go @@ -12,6 +12,16 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" ) +// archivedMessageColumns enumerates the messages columns copied into +// messages_archive (identical layout per migration 000139). Naming the columns +// makes schema drift between the two tables surface as an explicit SQL error +// instead of silently misaligned positional inserts. +const archivedMessageColumns = `id, tenant_id, command_type, op_id, ep_eui, bs_eui, rx_time, packet_cnt, + snr, rssi, user_data, dl_open, response_exp, dl_ack, rx_duration, eq_snr, profile, mode, format, + subpackets, received_at, processed_at, created_at, updated_at, org_uuid, owner_tenant_id, + base_stations, duplicate, decoded_payload, blueprint_type_eui, blueprint_version_id, + decode_status, decode_error_code, nwk_sn_key, archived, archived_at` + // ArchivalService handles message archiving operations type ArchivalService struct { db *sql.DB @@ -46,15 +56,15 @@ func (s *ArchivalService) ArchiveOldMessages(ctx context.Context, olderThan time }() // First, copy messages to archive table - query := ` - INSERT INTO messages_archive - SELECT * FROM messages - WHERE received_at < $1 + query := fmt.Sprintf(` + INSERT INTO messages_archive (%s) + SELECT %s FROM messages + WHERE received_at < $1 AND archived = false AND NOT EXISTS ( - SELECT 1 FROM messages_archive + SELECT 1 FROM messages_archive WHERE messages_archive.id = messages.id - )` + )`, archivedMessageColumns, archivedMessageColumns) result, err := tx.ExecContext(ctx, query, cutoffTime) if err != nil { diff --git a/KC-DB/storage/postgres/archival_test.go b/KC-DB/storage/postgres/archival_test.go new file mode 100644 index 0000000..e3b70f4 --- /dev/null +++ b/KC-DB/storage/postgres/archival_test.go @@ -0,0 +1,170 @@ +package postgres + +import ( + "context" + "encoding/binary" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// archivalMessageParams describes a minimal messages row for archival tests. +type archivalMessageParams struct { + TenantID int64 + EpEUI uint64 + BsEUI uint64 + ReceivedAt time.Time +} + +// insertArchivalMessage inserts a messages row satisfying all NOT NULL columns +// of the current schema (migration 000047 layout with BYTEA EUIs per 000135). +// Returns the generated message UUID. +func insertArchivalMessage(t *testing.T, db *sqlx.DB, p archivalMessageParams) string { + t.Helper() + + epEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(epEUIBytes, p.EpEUI) + bsEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(bsEUIBytes, p.BsEUI) + + var id string + err := db.QueryRow(` + INSERT INTO messages ( + tenant_id, op_id, ep_eui, bs_eui, rx_time, packet_cnt, + snr, rssi, user_data, received_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + `, p.TenantID, p.ReceivedAt.UnixNano(), epEUIBytes, bsEUIBytes, + p.ReceivedAt.UnixNano(), 1, 10.0, -80.0, []byte{0x42}, p.ReceivedAt).Scan(&id) + require.NoError(t, err, "Failed to insert test message") + + return id +} + +// TestArchivalServiceMessages verifies the message archival lifecycle against +// the current messages/messages_archive schema: copy-then-mark on the first +// sweep, idempotency on re-run, BYTEA EUI round-trip for high-bit EUI64 +// values, archival statistics, and the purge of already-archived rows. +func TestArchivalServiceMessages(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sqlxDB, cleanup := SetupPostgresContainer(t) + defer cleanup() + + createTestTenant(t, sqlxDB, 100, "TestTenantArchival") + + logger.Initialize("error", "json") + log := logger.Get() + service := NewArchivalService(sqlxDB.DB, log) + + ctx := context.Background() + + // High-bit EUI64 (> max int64) proves the BYTEA storage path survives the + // archival round-trip without signed overflow. + const highBitEpEUI = uint64(0xCAFECAFECAFECAFE) + const bsEUI = uint64(0x70B3D59CD00009E6) + const retention = 90 * 24 * time.Hour + + oldTime := time.Now().Add(-100 * 24 * time.Hour) + for i := 0; i < 5; i++ { + insertArchivalMessage(t, sqlxDB, archivalMessageParams{ + TenantID: 100, + EpEUI: highBitEpEUI, + BsEUI: bsEUI, + ReceivedAt: oldTime.Add(time.Duration(i) * time.Hour), + }) + } + + recentTime := time.Now().Add(-1 * time.Hour) + for i := 0; i < 3; i++ { + insertArchivalMessage(t, sqlxDB, archivalMessageParams{ + TenantID: 100, + EpEUI: highBitEpEUI, + BsEUI: bsEUI, + ReceivedAt: recentTime.Add(time.Duration(i) * time.Minute), + }) + } + + t.Run("ArchiveOldMessages", func(t *testing.T) { + count, err := service.ArchiveOldMessages(ctx, retention) + require.NoError(t, err) + assert.Equal(t, int64(5), count, "only messages older than retention must be archived") + + var archivedCount int + require.NoError(t, sqlxDB.QueryRow("SELECT COUNT(*) FROM messages_archive").Scan(&archivedCount)) + assert.Equal(t, 5, archivedCount) + + var markedCount int + require.NoError(t, sqlxDB.QueryRow( + "SELECT COUNT(*) FROM messages WHERE archived = true").Scan(&markedCount)) + assert.Equal(t, 5, markedCount, "archived rows must be flagged in the main table") + + var unmarkedCount int + require.NoError(t, sqlxDB.QueryRow( + "SELECT COUNT(*) FROM messages WHERE archived = false").Scan(&unmarkedCount)) + assert.Equal(t, 3, unmarkedCount, "recent rows must remain unflagged") + + var stampedCount int + require.NoError(t, sqlxDB.QueryRow( + "SELECT COUNT(*) FROM messages_archive WHERE archived_at IS NOT NULL").Scan(&stampedCount)) + assert.Equal(t, 5, stampedCount, "archive trigger must stamp archived_at on every copied row") + }) + + t.Run("HighBitEUIRoundTrip", func(t *testing.T) { + rows, err := sqlxDB.Query("SELECT ep_eui, bs_eui FROM messages_archive") + require.NoError(t, err) + defer func() { _ = rows.Close() }() + + count := 0 + for rows.Next() { + var epEUIBytes, bsEUIBytes []byte + require.NoError(t, rows.Scan(&epEUIBytes, &bsEUIBytes)) + require.Len(t, epEUIBytes, 8) + require.Len(t, bsEUIBytes, 8) + assert.Equal(t, highBitEpEUI, binary.BigEndian.Uint64(epEUIBytes), + "high-bit EP EUI must survive the archival round-trip unchanged") + assert.Equal(t, bsEUI, binary.BigEndian.Uint64(bsEUIBytes)) + count++ + } + require.NoError(t, rows.Err()) + assert.Equal(t, 5, count) + }) + + t.Run("SecondRunIsIdempotent", func(t *testing.T) { + count, err := service.ArchiveOldMessages(ctx, retention) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "re-running archival must not copy rows twice") + + var archivedCount int + require.NoError(t, sqlxDB.QueryRow("SELECT COUNT(*) FROM messages_archive").Scan(&archivedCount)) + assert.Equal(t, 5, archivedCount) + }) + + t.Run("ArchivalStats", func(t *testing.T) { + stats, err := service.GetArchivalStats(ctx) + require.NoError(t, err) + assert.Equal(t, int64(8), stats.MainTableCount) + assert.Equal(t, int64(5), stats.MainTableArchived) + assert.Equal(t, int64(5), stats.ArchiveTableCount) + }) + + t.Run("PurgeArchivedMessages", func(t *testing.T) { + purged, err := service.PurgeArchivedMessages(ctx, time.Now().Add(time.Minute)) + require.NoError(t, err) + assert.Equal(t, int64(5), purged, "purge must remove exactly the archived rows") + + var remaining int + require.NoError(t, sqlxDB.QueryRow("SELECT COUNT(*) FROM messages").Scan(&remaining)) + assert.Equal(t, 3, remaining, "recent unarchived rows must survive the purge") + + var archiveCount int + require.NoError(t, sqlxDB.QueryRow("SELECT COUNT(*) FROM messages_archive").Scan(&archiveCount)) + assert.Equal(t, 5, archiveCount, "purge must not touch the archive table") + }) +} diff --git a/KC-DB/storage/postgres/archival_test.go.broken b/KC-DB/storage/postgres/archival_test.go.broken deleted file mode 100644 index 62d0ae4..0000000 --- a/KC-DB/storage/postgres/archival_test.go.broken +++ /dev/null @@ -1,446 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/kilocenter/KC-Core/pkg/logger" -) - -// TestArchivalService tests the archival service functionality -func TestArchivalService(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - // Initialize test database - db, cleanup := setupTestDB(t) - defer cleanup() - - // Initialize logger - logger.Initialize("debug", "text") - log := logger.Get() - - // Create archival service - archivalService := NewArchivalService(db, log) - - // Run tests - t.Run("ArchiveOldMessages", func(t *testing.T) { - testArchiveOldMessages(t, db, archivalService) - }) - - t.Run("ArchiveOldGatewayReceptions", func(t *testing.T) { - testArchiveOldGatewayReceptions(t, db, archivalService) - }) - - t.Run("ArchiveOldDeviceSessions", func(t *testing.T) { - testArchiveOldDeviceSessions(t, db, archivalService) - }) - - t.Run("ArchiveOldDeviceKeys", func(t *testing.T) { - testArchiveOldDeviceKeys(t, db, archivalService) - }) -} - -func testArchiveOldMessages(t *testing.T, db *sql.DB, service *ArchivalService) { - ctx := context.Background() - - // Create test data - tenantID := createTestTenant(t, db) - deviceID := createTestDevice(t, db, tenantID) - - // Insert old messages (older than retention period) - oldTime := time.Now().Add(-100 * 24 * time.Hour) // 100 days ago - for i := 0; i < 5; i++ { - _, err := db.ExecContext(ctx, ` - INSERT INTO messages (device_id, tenant_id, received_at, payload, rssi, snr) - VALUES ($1, $2, $3, $4, $5, $6) - `, deviceID, tenantID, oldTime.Add(time.Duration(i)*time.Hour), []byte("test"), -80.0, 10.0) - require.NoError(t, err) - } - - // Insert recent messages (should not be archived) - recentTime := time.Now().Add(-1 * time.Hour) - for i := 0; i < 3; i++ { - _, err := db.ExecContext(ctx, ` - INSERT INTO messages (device_id, tenant_id, received_at, payload, rssi, snr) - VALUES ($1, $2, $3, $4, $5, $6) - `, deviceID, tenantID, recentTime.Add(time.Duration(i)*time.Minute), []byte("test"), -80.0, 10.0) - require.NoError(t, err) - } - - // Run archival - retention := 90 * 24 * time.Hour - count, err := service.ArchiveOldMessages(ctx, retention) - require.NoError(t, err) - assert.Equal(t, 5, count) - - // Verify old messages are archived - var archivedCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages_archive").Scan(&archivedCount) - require.NoError(t, err) - assert.Equal(t, 5, archivedCount) - - // Verify recent messages remain - var remainingCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&remainingCount) - require.NoError(t, err) - assert.Equal(t, 3, remainingCount) -} - -func testArchiveOldGatewayReceptions(t *testing.T, db *sql.DB, service *ArchivalService) { - ctx := context.Background() - - // Create test data - tenantID := createTestTenant(t, db) - gatewayID := createTestGateway(t, db, tenantID) - deviceID := createTestDevice(t, db, tenantID) - messageID := createTestMessage(t, db, deviceID, tenantID) - - // Insert old gateway receptions - oldTime := time.Now().Add(-35 * 24 * time.Hour) // 35 days ago - for i := 0; i < 3; i++ { - _, err := db.ExecContext(ctx, ` - INSERT INTO gateway_receptions (message_id, gateway_id, rssi, snr, created_at) - VALUES ($1, $2, $3, $4, $5) - `, messageID, gatewayID, -80.0+float64(i), 10.0+float64(i), oldTime.Add(time.Duration(i)*time.Hour)) - require.NoError(t, err) - } - - // Insert recent gateway receptions - recentMessageID := createTestMessage(t, db, deviceID, tenantID) - recentTime := time.Now().Add(-1 * time.Hour) - for i := 0; i < 2; i++ { - _, err := db.ExecContext(ctx, ` - INSERT INTO gateway_receptions (message_id, gateway_id, rssi, snr, created_at) - VALUES ($1, $2, $3, $4, $5) - `, recentMessageID, gatewayID, -75.0+float64(i), 12.0+float64(i), recentTime.Add(time.Duration(i)*time.Minute)) - require.NoError(t, err) - } - - // Run archival - retention := 30 * 24 * time.Hour - count, err := service.ArchiveOldGatewayReceptions(ctx, retention) - require.NoError(t, err) - assert.Equal(t, 3, count) - - // Verify old receptions are archived - var archivedCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM gateway_receptions_archive").Scan(&archivedCount) - require.NoError(t, err) - assert.Equal(t, 3, archivedCount) - - // Verify recent receptions remain - var remainingCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM gateway_receptions").Scan(&remainingCount) - require.NoError(t, err) - assert.Equal(t, 2, remainingCount) -} - -func testArchiveOldDeviceSessions(t *testing.T, db *sql.DB, service *ArchivalService) { - ctx := context.Background() - - // Create test data - tenantID := createTestTenant(t, db) - deviceID := createTestDevice(t, db, tenantID) - - // Insert old terminated sessions - oldTime := time.Now().Add(-200 * 24 * time.Hour) // 200 days ago - for i := 0; i < 4; i++ { - endTime := oldTime.Add(time.Duration(i+1) * time.Hour) - _, err := db.ExecContext(ctx, ` - INSERT INTO device_sessions (device_id, tenant_id, started_at, ended_at, status) - VALUES ($1, $2, $3, $4, $5) - `, deviceID, tenantID, oldTime.Add(time.Duration(i)*time.Hour), endTime, "terminated") - require.NoError(t, err) - } - - // Insert active session (should not be archived) - _, err := db.ExecContext(ctx, ` - INSERT INTO device_sessions (device_id, tenant_id, started_at, status) - VALUES ($1, $2, $3, $4) - `, deviceID, tenantID, time.Now().Add(-1*time.Hour), "active") - require.NoError(t, err) - - // Run archival - retention := 180 * 24 * time.Hour - count, err := service.ArchiveOldDeviceSessions(ctx, retention) - require.NoError(t, err) - assert.Equal(t, 4, count) - - // Verify old sessions are archived - var archivedCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM device_sessions_archive").Scan(&archivedCount) - require.NoError(t, err) - assert.Equal(t, 4, archivedCount) - - // Verify active session remains - var remainingCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM device_sessions WHERE status = 'active'").Scan(&remainingCount) - require.NoError(t, err) - assert.Equal(t, 1, remainingCount) -} - -func testArchiveOldDeviceKeys(t *testing.T, db *sql.DB, service *ArchivalService) { - ctx := context.Background() - - // Create test data - tenantID := createTestTenant(t, db) - deviceID := createTestDevice(t, db, tenantID) - - // Insert old inactive keys - oldTime := time.Now().Add(-400 * 24 * time.Hour) // 400 days ago - for i := 0; i < 3; i++ { - validUntil := oldTime.Add(time.Duration(i+1) * 24 * time.Hour) - _, err := db.ExecContext(ctx, ` - INSERT INTO device_keys (device_id, tenant_id, key_type, key_value, created_at, valid_until, active) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, deviceID, tenantID, "network", "test_key_"+string(rune(i)), oldTime.Add(time.Duration(i)*time.Hour), validUntil, false) - require.NoError(t, err) - } - - // Insert one recent inactive key (should be kept for history) - recentTime := time.Now().Add(-30 * 24 * time.Hour) - _, err := db.ExecContext(ctx, ` - INSERT INTO device_keys (device_id, tenant_id, key_type, key_value, created_at, valid_until, active) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, deviceID, tenantID, "network", "recent_inactive_key", recentTime, recentTime.Add(7*24*time.Hour), false) - require.NoError(t, err) - - // Insert active key (should not be archived) - _, err = db.ExecContext(ctx, ` - INSERT INTO device_keys (device_id, tenant_id, key_type, key_value, created_at, active) - VALUES ($1, $2, $3, $4, $5, $6) - `, deviceID, tenantID, "network", "active_key", time.Now().Add(-1*time.Hour), true) - require.NoError(t, err) - - // Run archival - retention := 365 * 24 * time.Hour - count, err := service.ArchiveOldDeviceKeys(ctx, retention) - require.NoError(t, err) - assert.Equal(t, 3, count) // Only the very old keys should be archived - - // Verify old keys are archived - var archivedCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM device_keys_archive").Scan(&archivedCount) - require.NoError(t, err) - assert.Equal(t, 3, archivedCount) - - // Verify active and recent inactive keys remain - var remainingCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM device_keys").Scan(&remainingCount) - require.NoError(t, err) - assert.Equal(t, 2, remainingCount) // Active key + recent inactive key -} - -// TestArchivalScheduler tests the archival scheduler functionality -func TestArchivalScheduler(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - // Initialize test database - db, cleanup := setupTestDB(t) - defer cleanup() - - // Initialize logger - logger.Initialize("debug", "text") - log := logger.Get() - - // Create archival service - archivalService := NewArchivalService(db, log) - - // Create scheduler with short intervals for testing - config := ArchivalConfig{ - MessageRetentionDays: 1, - MessageArchivalHour: time.Now().Hour(), - GatewayReceptionRetentionDays: 1, - GatewayReceptionArchivalHour: time.Now().Hour(), - DeviceSessionRetentionDays: 1, - DeviceSessionArchivalHour: time.Now().Hour(), - DeviceKeyRetentionDays: 1, - DeviceKeyArchivalHour: time.Now().Hour(), - ArchivalEnabled: true, - CheckInterval: 1 * time.Second, // Very short for testing - } - - scheduler := NewArchivalScheduler(archivalService, log, config) - - // Start scheduler - err := scheduler.Start() - require.NoError(t, err) - defer scheduler.Stop() - - // Verify scheduler is running - status := scheduler.GetStatus() - running, ok := status["running"].(bool) - assert.True(t, ok) - assert.True(t, running) - - // Create test data that should be archived - ctx := context.Background() - tenantID := createTestTenant(t, db) - deviceID := createTestDevice(t, db, tenantID) - - // Insert old message - oldTime := time.Now().Add(-2 * 24 * time.Hour) - _, err = db.ExecContext(ctx, ` - INSERT INTO messages (device_id, tenant_id, received_at, payload, rssi, snr) - VALUES ($1, $2, $3, $4, $5, $6) - `, deviceID, tenantID, oldTime, []byte("test"), -80.0, 10.0) - require.NoError(t, err) - - // Wait for scheduler to run (it should run immediately on start and then after CheckInterval) - time.Sleep(3 * time.Second) - - // Verify message was archived - var archivedCount int - err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages_archive").Scan(&archivedCount) - require.NoError(t, err) - assert.Equal(t, 1, archivedCount) - - // Check scheduler status again - status = scheduler.GetStatus() - lastRuns, ok := status["last_runs"].(map[string]string) - assert.True(t, ok) - assert.NotEmpty(t, lastRuns["messages"]) -} - -// TestArchivalPerformance tests the performance of archival operations -func TestArchivalPerformance(t *testing.T) { - if testing.Short() { - t.Skip("Skipping performance test") - } - - // Initialize test database - db, cleanup := setupTestDB(t) - defer cleanup() - - // Initialize logger - logger.Initialize("info", "text") - log := logger.Get() - - // Create archival service - archivalService := NewArchivalService(db, log) - ctx := context.Background() - - // Create test data - tenantID := createTestTenant(t, db) - deviceID := createTestDevice(t, db, tenantID) - gatewayID := createTestGateway(t, db, tenantID) - - // Performance test for message archival - t.Run("MessageArchivalPerformance", func(t *testing.T) { - // Insert 10,000 old messages - oldTime := time.Now().Add(-100 * 24 * time.Hour) - - start := time.Now() - for i := 0; i < 10000; i++ { - _, err := db.ExecContext(ctx, ` - INSERT INTO messages (device_id, tenant_id, received_at, payload, rssi, snr) - VALUES ($1, $2, $3, $4, $5, $6) - `, deviceID, tenantID, oldTime.Add(time.Duration(i)*time.Second), []byte("test"), -80.0, 10.0) - require.NoError(t, err) - } - insertDuration := time.Since(start) - t.Logf("Inserted 10,000 messages in %v", insertDuration) - - // Run archival - start = time.Now() - retention := 90 * 24 * time.Hour - count, err := archivalService.ArchiveOldMessages(ctx, retention) - require.NoError(t, err) - archivalDuration := time.Since(start) - - assert.Equal(t, 10000, count) - t.Logf("Archived 10,000 messages in %v (%.2f messages/second)", - archivalDuration, float64(count)/archivalDuration.Seconds()) - - // Performance should be reasonable - assert.Less(t, archivalDuration.Seconds(), 30.0, "Archival took too long") - }) - - // Performance test for gateway reception archival - t.Run("GatewayReceptionArchivalPerformance", func(t *testing.T) { - // Create a message for the receptions - messageID := createTestMessage(t, db, deviceID, tenantID) - - // Insert 5,000 old gateway receptions - oldTime := time.Now().Add(-35 * 24 * time.Hour) - - start := time.Now() - for i := 0; i < 5000; i++ { - _, err := db.ExecContext(ctx, ` - INSERT INTO gateway_receptions (message_id, gateway_id, rssi, snr, created_at) - VALUES ($1, $2, $3, $4, $5) - `, messageID, gatewayID, -80.0, 10.0, oldTime.Add(time.Duration(i)*time.Second)) - require.NoError(t, err) - } - insertDuration := time.Since(start) - t.Logf("Inserted 5,000 gateway receptions in %v", insertDuration) - - // Run archival - start = time.Now() - retention := 30 * 24 * time.Hour - count, err := archivalService.ArchiveOldGatewayReceptions(ctx, retention) - require.NoError(t, err) - archivalDuration := time.Since(start) - - assert.Equal(t, 5000, count) - t.Logf("Archived 5,000 gateway receptions in %v (%.2f receptions/second)", - archivalDuration, float64(count)/archivalDuration.Seconds()) - - // Performance should be reasonable - assert.Less(t, archivalDuration.Seconds(), 15.0, "Archival took too long") - }) -} - -// Helper functions for creating test data -func createTestTenant(t *testing.T, db *sql.DB) int64 { - var id int64 - err := db.QueryRow(` - INSERT INTO tenants (name, description) - VALUES ('test_tenant', 'Test tenant for archival') - RETURNING id - `).Scan(&id) - require.NoError(t, err) - return id -} - -func createTestDevice(t *testing.T, db *sql.DB, tenantID int64) int64 { - var id int64 - err := db.QueryRow(` - INSERT INTO devices (device_eui, tenant_id, name, device_class, status) - VALUES ('0102030405060708', $1, 'test_device', 'A', 'active') - RETURNING id - `, tenantID).Scan(&id) - require.NoError(t, err) - return id -} - -func createTestGateway(t *testing.T, db *sql.DB, tenantID int64) int64 { - var id int64 - err := db.QueryRow(` - INSERT INTO gateways (gateway_eui, tenant_id, name, latitude, longitude, altitude) - VALUES ('0807060504030201', $1, 'test_gateway', 52.5200, 13.4050, 100.0) - RETURNING id - `, tenantID).Scan(&id) - require.NoError(t, err) - return id -} - -func createTestMessage(t *testing.T, db *sql.DB, deviceID, tenantID int64) int64 { - var id int64 - err := db.QueryRow(` - INSERT INTO messages (device_id, tenant_id, received_at, payload, rssi, snr) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id - `, deviceID, tenantID, time.Now(), []byte("test"), -80.0, 10.0).Scan(&id) - require.NoError(t, err) - return id -} diff --git a/KC-DB/storage/postgres/basestation_repository_test.go b/KC-DB/storage/postgres/basestation_repository_test.go index 36a333d..707df1b 100644 --- a/KC-DB/storage/postgres/basestation_repository_test.go +++ b/KC-DB/storage/postgres/basestation_repository_test.go @@ -38,6 +38,13 @@ func cleanupBaseStationTestData(t *testing.T, db *sqlx.DB, namePattern string) { // TestBaseStationRepository_Create_RejectsCrossTenantDuplicate verifies that // migration 000080 enforces global EUI uniqueness across all tenants for base stations // **CRITICAL**: Requires testcontainer with migration 000080 applied (constraint verified in testcontainer.go) +// testServiceCenterURLPtr satisfies the check_bssci_config constraint for +// bssci-type fixtures (service_center_url must be present). +func testServiceCenterURLPtr() *string { + url := "tls://kilocenter.local:5000" + return &url +} + func TestBaseStationRepository_Create_RejectsCrossTenantDuplicate(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") @@ -60,10 +67,11 @@ func TestBaseStationRepository_Create_RejectsCrossTenantDuplicate(t *testing.T) // Create base station in tenant 100 - should succeed bs1 := &models.BaseStation{ - EUI: eui, - TenantID: 100, - Name: "TestCrossTenant-T100-BS1", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: eui, + TenantID: 100, + Name: "TestCrossTenant-T100-BS1", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err := repo.Create(ctx, bs1) @@ -72,10 +80,11 @@ func TestBaseStationRepository_Create_RejectsCrossTenantDuplicate(t *testing.T) // Attempt to create same EUI in tenant 200 - should fail with global uniqueness violation bs2 := &models.BaseStation{ - EUI: eui, // Same EUI as tenant 100 - TenantID: 200, // Different tenant - Name: "TestCrossTenant-T200-BS2", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: eui, // Same EUI as tenant 100 + TenantID: 200, // Different tenant + Name: "TestCrossTenant-T200-BS2", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err = repo.Create(ctx, bs2) @@ -112,10 +121,11 @@ func TestBaseStationRepository_Create_AllowsSameTenantDifferentEUI(t *testing.T) // Create first base station bs1 := &models.BaseStation{ - EUI: models.EUI{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}, - TenantID: 100, - Name: "TestSameTenant-BS1", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: models.EUI{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}, + TenantID: 100, + Name: "TestSameTenant-BS1", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err := repo.Create(ctx, bs1) @@ -124,10 +134,11 @@ func TestBaseStationRepository_Create_AllowsSameTenantDifferentEUI(t *testing.T) // Create second base station with different EUI - should succeed bs2 := &models.BaseStation{ - EUI: models.EUI{0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22}, // Different EUI - TenantID: 100, // Same tenant - Name: "TestSameTenant-BS2", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: models.EUI{0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22}, // Different EUI + TenantID: 100, // Same tenant + Name: "TestSameTenant-BS2", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err = repo.Create(ctx, bs2) @@ -160,10 +171,11 @@ func TestBaseStationRepository_UpdateEUI_Success(t *testing.T) { // Create base station with old EUI bs := &models.BaseStation{ - EUI: oldEui, - TenantID: 100, - Name: "TestUpdateEUI-BS1", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: oldEui, + TenantID: 100, + Name: "TestUpdateEUI-BS1", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err := repo.Create(ctx, bs) @@ -201,19 +213,21 @@ func TestBaseStationRepository_UpdateEUI_ErrAlreadyExists(t *testing.T) { // Create two base stations with different EUIs bs1 := &models.BaseStation{ - EUI: eui1, - TenantID: 100, - Name: "TestUpdateEUIExists-BS1", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: eui1, + TenantID: 100, + Name: "TestUpdateEUIExists-BS1", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err := repo.Create(ctx, bs1) require.NoError(t, err) bs2 := &models.BaseStation{ - EUI: eui2, - TenantID: 100, - Name: "TestUpdateEUIExists-BS2", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: eui2, + TenantID: 100, + Name: "TestUpdateEUIExists-BS2", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err = repo.Create(ctx, bs2) require.NoError(t, err) @@ -245,10 +259,11 @@ func TestBaseStationRepository_UpdateEUI_ErrNotFound(t *testing.T) { // Create base station in tenant 100 bs := &models.BaseStation{ - EUI: oldEui, - TenantID: 100, - Name: "TestUpdateEUINotFound-BS1", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: oldEui, + TenantID: 100, + Name: "TestUpdateEUINotFound-BS1", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err := repo.Create(ctx, bs) require.NoError(t, err) @@ -438,34 +453,37 @@ func TestBaseStationRepository_ListAllLocations(t *testing.T) { // Tenant 100: base station WITH coordinates bs1 := &models.BaseStation{ - EUI: models.EUI{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, - TenantID: 100, - Name: "TestListAllLoc-A-WithCoords", - ConnectionType: models.ConnectionTypeBSSCI, - Latitude: &lat1, - Longitude: &lon1, + EUI: models.EUI{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + TenantID: 100, + Name: "TestListAllLoc-A-WithCoords", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), + Latitude: &lat1, + Longitude: &lon1, } err := repo.Create(ctx, bs1) require.NoError(t, err) // Tenant 200: base station WITH coordinates bs2 := &models.BaseStation{ - EUI: models.EUI{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}, - TenantID: 200, - Name: "TestListAllLoc-B-WithCoords", - ConnectionType: models.ConnectionTypeBSSCI, - Latitude: &lat2, - Longitude: &lon2, + EUI: models.EUI{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}, + TenantID: 200, + Name: "TestListAllLoc-B-WithCoords", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), + Latitude: &lat2, + Longitude: &lon2, } err = repo.Create(ctx, bs2) require.NoError(t, err) // Tenant 100: base station WITHOUT coordinates bs3 := &models.BaseStation{ - EUI: models.EUI{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28}, - TenantID: 100, - Name: "TestListAllLoc-C-NoCoords", - ConnectionType: models.ConnectionTypeBSSCI, + EUI: models.EUI{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28}, + TenantID: 100, + Name: "TestListAllLoc-C-NoCoords", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), } err = repo.Create(ctx, bs3) require.NoError(t, err) diff --git a/KC-DB/storage/postgres/basestation_session_repository.go b/KC-DB/storage/postgres/basestation_session_repository.go index 51b44c6..01a137a 100644 --- a/KC-DB/storage/postgres/basestation_session_repository.go +++ b/KC-DB/storage/postgres/basestation_session_repository.go @@ -3,6 +3,7 @@ package postgres import ( "context" "database/sql" + "errors" "fmt" "log" "strings" @@ -122,7 +123,7 @@ func (r *BaseStationSessionRepository) GetActiveSessionByBaseStation(ctx context encoding, protocol_version, connect_info, created_at, updated_at FROM basestation_sessions - WHERE basestation_id = $1 AND tenant_id = $2 AND status IN ('active', 'resumed') + WHERE basestation_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY started_at DESC LIMIT 1` @@ -203,12 +204,26 @@ func (r *BaseStationSessionRepository) UpdateSession(ctx context.Context, tenant argPos++ } + if req.EndedAt != nil && req.ClearEndedAt { + return fmt.Errorf("update request cannot set and clear ended_at at once") + } + if req.EndedAt != nil { updates = append(updates, fmt.Sprintf("ended_at = $%d", argPos)) args = append(args, *req.EndedAt) argPos++ } + if req.ClearEndedAt { + updates = append(updates, "ended_at = NULL") + } + + if req.CanResume != nil { + updates = append(updates, fmt.Sprintf("can_resume = $%d", argPos)) + args = append(args, *req.CanResume) + argPos++ + } + if req.ConnectionId != nil { updates = append(updates, fmt.Sprintf("connection_id = $%d", argPos)) args = append(args, *req.ConnectionId) @@ -327,7 +342,7 @@ func (r *BaseStationSessionRepository) UpdatePing(ctx context.Context, tenantID, // UpdateEncoding updates the message encoding for a session // This is called when encoding is negotiated on first message per BSSCI Section 1 -func (r *BaseStationSessionRepository) UpdateEncoding(ctx context.Context, sessionID int64, encoding string) error { +func (r *BaseStationSessionRepository) UpdateEncoding(ctx context.Context, tenantID, sessionID int64, encoding string) error { // Validate encoding value if encoding != bssci.EncodingJSON && encoding != bssci.EncodingMessagePack { return fmt.Errorf("invalid encoding: must be '%s' or '%s', got '%s'", bssci.EncodingJSON, bssci.EncodingMessagePack, encoding) @@ -337,9 +352,9 @@ func (r *BaseStationSessionRepository) UpdateEncoding(ctx context.Context, sessi UPDATE basestation_sessions SET encoding = $1, updated_at = NOW() - WHERE id = $2` + WHERE id = $2 AND tenant_id = $3` - result, err := r.db.ExecContext(ctx, query, encoding, sessionID) + result, err := r.db.ExecContext(ctx, query, encoding, sessionID, tenantID) if err != nil { return fmt.Errorf("failed to update encoding: %w", err) } @@ -350,7 +365,7 @@ func (r *BaseStationSessionRepository) UpdateEncoding(ctx context.Context, sessi } if rowsAffected == 0 { - return fmt.Errorf("session not found: id=%d", sessionID) + return fmt.Errorf("session not found: id=%d tenant=%d", sessionID, tenantID) } return nil @@ -361,6 +376,7 @@ func (r *BaseStationSessionRepository) TerminateSession(ctx context.Context, ten query := ` UPDATE basestation_sessions SET status = $1, + can_resume = false, ended_at = $2, updated_at = $2 WHERE id = $3 AND tenant_id = $4` @@ -392,7 +408,7 @@ func (r *BaseStationSessionRepository) TerminateAllSessions(ctx context.Context, updated_at = $2 WHERE basestation_id = $3 AND tenant_id = $4 - AND status IN ('active', 'resumed')` + AND status = 'active'` now := time.Now() _, err := r.db.ExecContext(ctx, query, models.SessionStatusTerminated, now, baseStationID, tenantID) @@ -448,7 +464,7 @@ func (r *BaseStationSessionRepository) ListSessions(ctx context.Context, filter } if filter.ActiveOnly { - whereClauses = append(whereClauses, "status IN ('active', 'resumed')") + whereClauses = append(whereClauses, "status = 'active'") } if filter.Since != nil { @@ -516,9 +532,9 @@ func (r *BaseStationSessionRepository) GetSessionStatistics(ctx context.Context, query := ` SELECT COUNT(*) as total_sessions, - COUNT(*) FILTER (WHERE status IN ('active', 'resumed')) as active_sessions, + COUNT(*) FILTER (WHERE status = 'active') as active_sessions, COUNT(*) FILTER (WHERE status = 'terminated') as terminated_sessions, - COUNT(*) FILTER (WHERE can_resume = true AND status IN ('active', 'resumed')) as resumable_sessions, + COUNT(*) FILTER (WHERE can_resume = true AND status = 'disconnected') as resumable_sessions, COALESCE( AVG(EXTRACT(EPOCH FROM (COALESCE(ended_at, NOW()) - started_at)) / 3600), 0 @@ -775,15 +791,59 @@ func (r *BaseStationSessionRepository) scanSessionFromRows(rows *sql.Rows) (*mod return session, nil } -// UpdateCountersAndTimestamp updates operation counters by session UUID -// Replaces direct GetDB().Exec() calls in bssci.Server.updateSessionCounters (line 4288) -func (r *BaseStationSessionRepository) UpdateCountersAndTimestamp(ctx context.Context, sessionUUID [16]byte, bsOpId, scOpId int64) error { - _, err := r.db.ExecContext(ctx, ` +// MarkDisconnected marks a session disconnected and resumable, guarded by +// the stored connection ID: when a reconnect already replaced this +// connection, the update matches zero rows and the newer session is left +// untouched (not an error). +func (r *BaseStationSessionRepository) MarkDisconnected(ctx context.Context, tenantID, sessionID int64, connectionID string, endedAt time.Time) error { + query := ` UPDATE basestation_sessions - SET sn_bs_op_id = $1, - sn_sc_op_id = $2 - WHERE sn_sc_uuid = $3 - `, bsOpId, scOpId, sessionUUID[:]) // Convert [16]byte to []byte for pq driver + SET status = $1, + can_resume = true, + ended_at = $2, + updated_at = $2 + WHERE id = $3 AND tenant_id = $4 AND connection_id = $5` - return err + if _, err := r.db.ExecContext(ctx, query, models.SessionStatusDisconnected, endedAt, sessionID, tenantID, connectionID); err != nil { + return fmt.Errorf("failed to mark session disconnected: %w", err) + } + return nil +} + +// FindResumableSession finds the resumable session for a base station, +// scoped by tenant, base station EUI, and snBsUuid, requiring +// status=disconnected and can_resume=true (BSSCI §5.3.1) +func (r *BaseStationSessionRepository) FindResumableSession(ctx context.Context, tenantID int64, bsEUI []byte, snBsUUID [16]byte) (*models.BaseStationSession, error) { + query := ` + SELECT s.id, s.basestation_id, s.tenant_id, s.sn_bs_uuid, s.sn_sc_uuid, + s.sn_bs_op_id, s.sn_sc_op_id, s.status, s.connection_id, s.remote_addr, + s.started_at, s.last_ping_at, s.ended_at, s.can_resume, s.encoding, + s.protocol_version, s.connect_info, s.organization_id, s.created_at, s.updated_at + FROM basestation_sessions s + JOIN basestations b ON b.id = s.basestation_id + WHERE s.tenant_id = $1 + AND b.bs_eui = $2 + AND s.sn_bs_uuid = $3 + AND s.status = $4 + AND s.can_resume = true + ORDER BY s.started_at DESC + LIMIT 1` + + session := &models.BaseStationSession{} + var snBsUUIDBytes, snScUUIDBytes []byte + err := r.db.QueryRowContext(ctx, query, tenantID, bsEUI, snBsUUID[:], models.SessionStatusDisconnected).Scan( + &session.ID, &session.BaseStationID, &session.TenantID, &snBsUUIDBytes, &snScUUIDBytes, + &session.SnBsOpId, &session.SnScOpId, &session.Status, &session.ConnectionId, &session.RemoteAddr, + &session.StartedAt, &session.LastPingAt, &session.EndedAt, &session.CanResume, &session.Encoding, + &session.ProtocolVersion, &session.ConnectInfo, &session.OrganizationID, &session.CreatedAt, &session.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to find resumable session: %w", err) + } + copy(session.SnBsUuid[:], snBsUUIDBytes) + copy(session.SnScUuid[:], snScUUIDBytes) + return session, nil } diff --git a/KC-DB/storage/postgres/blueprint_repository.go b/KC-DB/storage/postgres/blueprint_repository.go index 3af5c00..2781db4 100644 --- a/KC-DB/storage/postgres/blueprint_repository.go +++ b/KC-DB/storage/postgres/blueprint_repository.go @@ -205,7 +205,7 @@ func (r *BlueprintRepository) ListByDeviceModel(ctx context.Context, tenantID in } if offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, offset) } @@ -246,7 +246,7 @@ func (r *BlueprintRepository) List(ctx context.Context, params *models.Blueprint } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } @@ -291,7 +291,7 @@ func (r *BlueprintRepository) ListWithModel(ctx context.Context, params *models. } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } diff --git a/KC-DB/storage/postgres/device_model_repository.go b/KC-DB/storage/postgres/device_model_repository.go index 69fd896..5d1a34d 100644 --- a/KC-DB/storage/postgres/device_model_repository.go +++ b/KC-DB/storage/postgres/device_model_repository.go @@ -156,7 +156,7 @@ func (r *DeviceModelRepository) ListByManufacturer(ctx context.Context, tenantID } if offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, offset) } @@ -210,7 +210,7 @@ func (r *DeviceModelRepository) List(ctx context.Context, params *models.DeviceM } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } @@ -258,7 +258,7 @@ func (r *DeviceModelRepository) ListWithManufacturer(ctx context.Context, params } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } diff --git a/KC-DB/storage/postgres/downlink_result_security_test.go.broken b/KC-DB/storage/postgres/downlink_result_security_test.go.broken deleted file mode 100644 index b7c0189..0000000 --- a/KC-DB/storage/postgres/downlink_result_security_test.go.broken +++ /dev/null @@ -1,384 +0,0 @@ -package postgres - -import ( - "context" - "encoding/binary" - "testing" - "time" - - "github.com/kilocenter/KC-DB/storage" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestUpdateDownlinkResultSecurityBoundaries tests security boundaries for UpdateDownlinkResult -// This is a regression test for BSSCI-3.14-Security compliance -func TestUpdateDownlinkResultSecurityBoundaries(t *testing.T) { - if testing.Short() { - t.Skip("Skipping database test in short mode") - } - - // Setup test database connection - db := setupSecurityTestDB(t) - defer cleanupSecurityTestDB(t, db) - - ctx := context.Background() - - // Create test data for different tenants - tenant1ID := "tenant-1" - tenant2ID := "tenant-2" - - epEui1 := uint64(0x1234567890ABCDEF) - epEui2 := uint64(0xFEDCBA0987654321) - - epEui1Bytes := make([]byte, 8) - epEui2Bytes := make([]byte, 8) - binary.BigEndian.PutUint64(epEui1Bytes, epEui1) - binary.BigEndian.PutUint64(epEui2Bytes, epEui2) - - bsEuiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(bsEuiBytes, 0x0123456789ABCDEF) - - // Create downlink messages for different tenants and endpoints - messages := []struct { - name string - msg *storage.DownlinkMessage - tenantID string - epEui []byte - }{ - { - name: "tenant1_ep1_msg1", - msg: &storage.DownlinkMessage{ - ID: "dl-001", - EpEui: epEui1Bytes, - TenantID: tenant1ID, - Payload: []byte("payload1"), - Port: 1, - Priority: 0.5, - Status: "pending", - QueueID: 100, - CreatedAt: time.Now(), - }, - tenantID: tenant1ID, - epEui: epEui1Bytes, - }, - { - name: "tenant1_ep2_msg2", - msg: &storage.DownlinkMessage{ - ID: "dl-002", - EpEui: epEui2Bytes, - TenantID: tenant1ID, - Payload: []byte("payload2"), - Port: 1, - Priority: 0.5, - Status: "pending", - QueueID: 101, - CreatedAt: time.Now(), - }, - tenantID: tenant1ID, - epEui: epEui2Bytes, - }, - { - name: "tenant2_ep1_msg3", - msg: &storage.DownlinkMessage{ - ID: "dl-003", - EpEui: epEui1Bytes, - TenantID: tenant2ID, - Payload: []byte("payload3"), - Port: 1, - Priority: 0.5, - Status: "pending", - QueueID: 102, - CreatedAt: time.Now(), - }, - tenantID: tenant2ID, - epEui: epEui1Bytes, - }, - } - - // Insert all test messages - for _, m := range messages { - err := db.CreateDownlinkMessage(ctx, m.msg) - require.NoError(t, err, "Failed to create message: %s", m.name) - } - - // Test cases for UpdateDownlinkResult security - tests := []struct { - name string - queueID int64 - result string - txTime *int64 - packetCnt *uint32 - bsEui []byte - epEui []byte - tenantID int64 - expectSuccess bool - expectedError error - description string - }{ - { - name: "valid update - correct tenant and endpoint", - queueID: 100, - result: "sent", - txTime: int64Ptr(1234567890), - packetCnt: uint32Ptr(42), - bsEui: bsEuiBytes, - epEui: epEui1Bytes, - tenantID: 1, // Matches tenant1ID when converted - expectSuccess: true, - description: "Should succeed when tenant and endpoint match", - }, - { - name: "cross-tenant attack - wrong tenant ID", - queueID: 100, - result: "sent", - txTime: int64Ptr(1234567890), - packetCnt: uint32Ptr(42), - bsEui: bsEuiBytes, - epEui: epEui1Bytes, - tenantID: 2, // Wrong tenant! - expectSuccess: false, - expectedError: storage.ErrDownlinkNotFound, - description: "Should fail when tenant ID doesn't match", - }, - { - name: "endpoint mismatch - wrong endpoint EUI", - queueID: 100, - result: "sent", - txTime: int64Ptr(1234567890), - packetCnt: uint32Ptr(42), - bsEui: bsEuiBytes, - epEui: epEui2Bytes, // Wrong endpoint! - tenantID: 1, - expectSuccess: false, - expectedError: storage.ErrDownlinkNotFound, - description: "Should fail when endpoint EUI doesn't match", - }, - { - name: "both wrong - tenant and endpoint mismatch", - queueID: 100, - result: "sent", - txTime: int64Ptr(1234567890), - packetCnt: uint32Ptr(42), - bsEui: bsEuiBytes, - epEui: epEui2Bytes, // Wrong endpoint! - tenantID: 2, // Wrong tenant! - expectSuccess: false, - expectedError: storage.ErrDownlinkNotFound, - description: "Should fail when both tenant and endpoint don't match", - }, - { - name: "non-existent queue ID", - queueID: 999, - result: "sent", - txTime: int64Ptr(1234567890), - packetCnt: uint32Ptr(42), - bsEui: bsEuiBytes, - epEui: epEui1Bytes, - tenantID: 1, - expectSuccess: false, - expectedError: storage.ErrDownlinkNotFound, - description: "Should fail for non-existent queue ID", - }, - { - name: "valid update - expired status", - queueID: 101, - result: "expired", - txTime: nil, // No txTime for expired - packetCnt: nil, // No packetCnt for expired - bsEui: bsEuiBytes, - epEui: epEui2Bytes, - tenantID: 1, - expectSuccess: true, - description: "Should succeed for expired status with correct tenant/endpoint", - }, - { - name: "cross-tenant on different endpoint", - queueID: 102, // This belongs to tenant2 - result: "sent", - txTime: int64Ptr(1234567890), - packetCnt: uint32Ptr(42), - bsEui: bsEuiBytes, - epEui: epEui1Bytes, - tenantID: 1, // Wrong tenant for this queue ID! - expectSuccess: false, - expectedError: storage.ErrDownlinkNotFound, - description: "Should fail when trying to update another tenant's queue", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Convert tenantID to string as stored in DB - tenantIDStr := convertTenantID(tt.tenantID) - - err := db.UpdateDownlinkResult(ctx, tt.queueID, tt.result, tt.txTime, - tt.packetCnt, tt.bsEui, tt.epEui, tt.tenantID) - - if tt.expectSuccess { - assert.NoError(t, err, tt.description) - - // Verify the update actually happened - results, _, err := db.GetDownlinkResults(ctx, "", tenantIDStr, "", nil, nil, 10, 0) - require.NoError(t, err) - - found := false - for _, r := range results { - if r.QueueID == tt.queueID { - found = true - assert.Equal(t, tt.result, r.Result, "Result should be updated") - if tt.txTime != nil { - assert.NotNil(t, r.TxTime, "TxTime should be set") - assert.Equal(t, *tt.txTime, *r.TxTime, "TxTime value should match") - } - break - } - } - assert.True(t, found, "Updated message should be found in results") - } else { - assert.Error(t, err, tt.description) - if tt.expectedError != nil { - assert.ErrorIs(t, err, tt.expectedError, "Should return specific error") - } - } - }) - } -} - -// TestUpdateDownlinkResultConcurrency tests concurrent updates don't violate security -func TestUpdateDownlinkResultConcurrency(t *testing.T) { - if testing.Short() { - t.Skip("Skipping database test in short mode") - } - - db := setupTestDB(t) - defer cleanupTestDB(t, db) - - ctx := context.Background() - - // Create messages for concurrent update attempts - epEuiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(epEuiBytes, 0x1234567890ABCDEF) - - bsEuiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(bsEuiBytes, 0x0123456789ABCDEF) - - // Create 10 messages across 2 tenants - for i := 0; i < 10; i++ { - tenantID := "1" - if i%2 == 0 { - tenantID = "2" - } - - msg := &storage.DownlinkMessage{ - ID: formatID("dl-concurrent-%d", i), - EpEui: epEuiBytes, - TenantID: tenantID, - Payload: []byte(formatPayload("payload-%d", i)), - Port: 1, - Priority: 0.5, - Status: "pending", - QueueID: int64(200 + i), - CreatedAt: time.Now(), - } - - err := db.CreateDownlinkMessage(ctx, msg) - require.NoError(t, err) - } - - // Run concurrent updates - done := make(chan bool, 10) - errors := make(chan error, 10) - - for i := 0; i < 10; i++ { - go func(index int) { - defer func() { done <- true }() - - // Tenant 1 tries to update all messages (should only succeed on odd indices) - err := db.UpdateDownlinkResult(ctx, - int64(200+index), - "sent", - int64Ptr(time.Now().Unix()), - uint32Ptr(uint32(index)), - bsEuiBytes, - epEuiBytes, - 1, // Always use tenant 1 - ) - - if index%2 == 0 { - // Even indices belong to tenant 2, should fail - if err == nil { - errors <- formatError("Expected error for queue %d but got success", 200+index) - } - } else { - // Odd indices belong to tenant 1, should succeed - if err != nil { - errors <- formatError("Expected success for queue %d but got error: %v", 200+index, err) - } - } - }(i) - } - - // Wait for all goroutines - for i := 0; i < 10; i++ { - <-done - } - close(errors) - - // Check for any errors - for err := range errors { - t.Error(err) - } - - // Verify final state - only tenant 1's messages should be updated - tenant1Results, _, err := db.GetDownlinkResults(ctx, "", "1", "transmitted", nil, nil, 20, 0) - require.NoError(t, err) - - tenant2Results, _, err := db.GetDownlinkResults(ctx, "", "2", "pending", nil, nil, 20, 0) - require.NoError(t, err) - - // Tenant 1 should have 5 transmitted messages (odd indices) - assert.Equal(t, 5, len(tenant1Results), "Tenant 1 should have 5 transmitted messages") - - // Tenant 2 should still have 5 pending messages (even indices) - assert.Equal(t, 5, len(tenant2Results), "Tenant 2 should have 5 pending messages") -} - -// Helper functions -func int64Ptr(v int64) *int64 { - return &v -} - -func uint32Ptr(v uint32) *uint32 { - return &v -} - -func convertTenantID(id int64) string { - if id == 1 { - return "tenant-1" - } - return "tenant-2" -} - -func formatID(format string, args ...interface{}) string { - // Simple format helper - return format // Simplified for test -} - -func formatPayload(format string, args ...interface{}) string { - return format // Simplified for test -} - -func formatError(format string, args ...interface{}) error { - return nil // Simplified for test -} - -// These would be implemented to connect to a test database -func setupSecurityTestDB(t *testing.T) *DB { - t.Skip("Database setup not implemented in this example") - return nil -} - -func cleanupSecurityTestDB(t *testing.T, db *DB) { - // Clean up test data -} diff --git a/KC-DB/storage/postgres/downlink_result_test.go b/KC-DB/storage/postgres/downlink_result_test.go new file mode 100644 index 0000000..67d8026 --- /dev/null +++ b/KC-DB/storage/postgres/downlink_result_test.go @@ -0,0 +1,420 @@ +package postgres + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "sync" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// euiHex converts an EUI64 to the lowercase hex form used by the results API. +func euiHex(eui uint64) string { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, eui) + return hex.EncodeToString(b) +} + +// setTransmittedAt backfills transmitted_at for a queued test downlink so +// time-range filters have deterministic data. +func setTransmittedAt(t *testing.T, db *DB, queID int64, at time.Time) { + t.Helper() + _, err := db.sqlxDB.Exec( + "UPDATE downlink_queue SET transmitted_at = $1 WHERE que_id = $2", at, queID) + require.NoError(t, err) +} + +// TestDownlinkResultsFiltering verifies GetDownlinkResults endpoint, status, +// and time-range filters against the current downlink_queue schema. +func TestDownlinkResultsFiltering(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sqlxDB, cleanup := SetupPostgresContainer(t) + defer cleanup() + + createTestTenant(t, sqlxDB, 100, "TestTenant100") + + logger.Initialize("error", "json") + log := logger.Get() + db := &DB{conn: sqlxDB.DB, sqlxDB: sqlxDB, log: log} + + epA := uint64(0x1234567890ABCDEF) + epB := uint64(0xFEDCBA0987654321) + insertEndpoint(t, sqlxDB, EndpointInsertParams{EpEUI: epA, Name: "TestEndpoint-Results-A", TenantID: 100}) + insertEndpoint(t, sqlxDB, EndpointInsertParams{EpEUI: epB, Name: "TestEndpoint-Results-B", TenantID: 100}) + + now := time.Now() + seed := []struct { + epEUI uint64 + status string + transmittedAt *time.Time + }{ + {epA, bssci.DLQueueStatusTransmitted, ptrTime(now.Add(-1 * time.Hour))}, + {epA, bssci.DLQueueStatusExpired, ptrTime(now.Add(-45 * time.Minute))}, + {epA, bssci.DLQueueStatusFailed, ptrTime(now.Add(-15 * time.Minute))}, + {epB, bssci.DLQueueStatusTransmitted, ptrTime(now.Add(-1 * time.Hour))}, + {epB, bssci.DLQueueStatusPending, nil}, + } + for _, s := range seed { + queID := insertDownlink(t, db, DownlinkInsertParams{ + EpEUI: s.epEUI, + TenantID: 100, + Status: s.status, + }) + if s.transmittedAt != nil { + setTransmittedAt(t, db, queID, *s.transmittedAt) + } + } + + tests := []struct { + name string + epEUI string + statusFilter string + timeFrom *time.Time + timeTo *time.Time + expectedCount int + expectedStatuses map[string]int + }{ + { + name: "no filters returns only result statuses", + expectedCount: 4, + expectedStatuses: map[string]int{ + bssci.DLQueueStatusTransmitted: 2, + bssci.DLQueueStatusExpired: 1, + bssci.DLQueueStatusFailed: 1, + }, + }, + { + name: "filter by endpoint", + epEUI: euiHex(epA), + expectedCount: 3, + expectedStatuses: map[string]int{ + bssci.DLQueueStatusTransmitted: 1, + bssci.DLQueueStatusExpired: 1, + bssci.DLQueueStatusFailed: 1, + }, + }, + { + name: "filter by status transmitted", + statusFilter: bssci.DLQueueStatusTransmitted, + expectedCount: 2, + expectedStatuses: map[string]int{ + bssci.DLQueueStatusTransmitted: 2, + }, + }, + { + name: "filter by endpoint and status", + epEUI: euiHex(epA), + statusFilter: bssci.DLQueueStatusTransmitted, + expectedCount: 1, + expectedStatuses: map[string]int{ + bssci.DLQueueStatusTransmitted: 1, + }, + }, + { + name: "filter by time range", + timeFrom: ptrTime(now.Add(-2 * time.Hour)), + timeTo: ptrTime(now.Add(-30 * time.Minute)), + expectedCount: 3, + expectedStatuses: map[string]int{ + bssci.DLQueueStatusTransmitted: 2, + bssci.DLQueueStatusExpired: 1, + }, + }, + { + name: "all filters combined", + epEUI: euiHex(epA), + statusFilter: bssci.DLQueueStatusTransmitted, + timeFrom: ptrTime(now.Add(-2 * time.Hour)), + timeTo: ptrTime(now), + expectedCount: 1, + expectedStatuses: map[string]int{ + bssci.DLQueueStatusTransmitted: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results, totalCount, err := db.GetDownlinkResults( + t.Context(), tt.epEUI, "100", nil, tt.statusFilter, tt.timeFrom, tt.timeTo, 10, 0) + require.NoError(t, err) + assert.Equal(t, tt.expectedCount, totalCount) + assert.Len(t, results, tt.expectedCount) + + gotStatuses := make(map[string]int, len(results)) + for _, r := range results { + gotStatuses[r.Status]++ + if tt.epEUI != "" { + assert.Equal(t, tt.epEUI, r.EPEUI, "results must match the endpoint filter") + } + } + assert.Equal(t, tt.expectedStatuses, gotStatuses) + }) + } + + t.Run("in-flight status filter is rejected", func(t *testing.T) { + _, _, err := db.GetDownlinkResults( + t.Context(), "", "100", nil, bssci.DLQueueStatusQueued, nil, nil, 10, 0) + require.Error(t, err, "non-terminal statuses are not valid result filters") + }) +} + +// TestDownlinkResultsTenantIsolation verifies GetDownlinkResults never leaks +// rows across tenants. +func TestDownlinkResultsTenantIsolation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sqlxDB, cleanup := SetupPostgresContainer(t) + defer cleanup() + + createTestTenant(t, sqlxDB, 100, "TestTenant100") + createTestTenant(t, sqlxDB, 200, "TestTenant200") + createTestTenant(t, sqlxDB, 300, "TestTenant300") + + logger.Initialize("error", "json") + log := logger.Get() + db := &DB{conn: sqlxDB.DB, sqlxDB: sqlxDB, log: log} + + epEUI := uint64(0x1234567890ABCDEF) + insertEndpoint(t, sqlxDB, EndpointInsertParams{EpEUI: epEUI, Name: "TestEndpoint-TenantIso", TenantID: 100}) + + seed := []struct { + tenantID int64 + status string + }{ + {100, bssci.DLQueueStatusTransmitted}, + {100, bssci.DLQueueStatusExpired}, + {100, bssci.DLQueueStatusFailed}, + {200, bssci.DLQueueStatusTransmitted}, + {200, bssci.DLQueueStatusExpired}, + } + for _, s := range seed { + insertDownlink(t, db, DownlinkInsertParams{ + EpEUI: epEUI, + TenantID: s.tenantID, + Status: s.status, + }) + } + + tests := []struct { + tenantID string + expectedCount int + }{ + {"100", 3}, + {"200", 2}, + {"300", 0}, + } + + for _, tt := range tests { + t.Run("tenant "+tt.tenantID, func(t *testing.T) { + results, totalCount, err := db.GetDownlinkResults( + t.Context(), "", tt.tenantID, nil, "", nil, nil, 10, 0) + require.NoError(t, err) + assert.Equal(t, tt.expectedCount, totalCount) + assert.Len(t, results, tt.expectedCount) + + for _, r := range results { + assert.Equal(t, tt.tenantID, r.TenantID, "results must belong to the requesting tenant") + } + }) + } +} + +// TestDownlinkResultSecurityBoundaries verifies the tenant and endpoint match +// requirements of UpdateDownlinkResult (BSSCI section 3.14 cross-tenant guard). +func TestDownlinkResultSecurityBoundaries(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sqlxDB, cleanup := SetupPostgresContainer(t) + defer cleanup() + + createTestTenant(t, sqlxDB, 100, "TestTenant100") + createTestTenant(t, sqlxDB, 200, "TestTenant200") + + logger.Initialize("error", "json") + log := logger.Get() + db := &DB{conn: sqlxDB.DB, sqlxDB: sqlxDB, log: log} + + epEUI := uint64(0x1234567890ABCDEF) + wrongEpEUI := uint64(0xFEDCBA0987654321) + insertEndpoint(t, sqlxDB, EndpointInsertParams{EpEUI: epEUI, Name: "TestEndpoint-Security", TenantID: 100}) + + epEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(epEUIBytes, epEUI) + wrongEpEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(wrongEpEUIBytes, wrongEpEUI) + bsEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(bsEUIBytes, uint64(0x0123456789ABCDEF)) + + queID := insertDownlink(t, db, DownlinkInsertParams{ + EpEUI: epEUI, + TenantID: 100, + Status: bssci.DLQueueStatusQueued, + }) + + txTime := time.Now().UnixNano() + packetCnt := uint32(42) + + resetQueued := func(t *testing.T) { + t.Helper() + _, err := sqlxDB.Exec( + "UPDATE downlink_queue SET status = $1 WHERE que_id = $2", + bssci.DLQueueStatusQueued, queID) + require.NoError(t, err) + } + + requireStatus := func(t *testing.T, want string) { + t.Helper() + var status string + require.NoError(t, sqlxDB.QueryRow( + "SELECT status FROM downlink_queue WHERE que_id = $1", queID).Scan(&status)) + assert.Equal(t, want, status) + } + + t.Run("same tenant and endpoint can update", func(t *testing.T) { + err := db.UpdateDownlinkResult( + t.Context(), queID, bssci.DLDataResultSent, &txTime, &packetCnt, + bsEUIBytes, epEUIBytes, "100", nil) + require.NoError(t, err) + requireStatus(t, bssci.DLQueueStatusTransmitted) + resetQueued(t) + }) + + t.Run("cross-tenant update is rejected", func(t *testing.T) { + err := db.UpdateDownlinkResult( + t.Context(), queID, bssci.DLDataResultSent, &txTime, &packetCnt, + bsEUIBytes, epEUIBytes, "200", nil) + assert.ErrorIs(t, err, storage.ErrDownlinkNotFound) + requireStatus(t, bssci.DLQueueStatusQueued) + }) + + t.Run("endpoint mismatch is rejected", func(t *testing.T) { + err := db.UpdateDownlinkResult( + t.Context(), queID, bssci.DLDataResultSent, &txTime, &packetCnt, + bsEUIBytes, wrongEpEUIBytes, "100", nil) + assert.ErrorIs(t, err, storage.ErrDownlinkNotFound) + requireStatus(t, bssci.DLQueueStatusQueued) + }) + + t.Run("tenant and endpoint both wrong is rejected", func(t *testing.T) { + err := db.UpdateDownlinkResult( + t.Context(), queID, bssci.DLDataResultSent, &txTime, &packetCnt, + bsEUIBytes, wrongEpEUIBytes, "200", nil) + assert.ErrorIs(t, err, storage.ErrDownlinkNotFound) + requireStatus(t, bssci.DLQueueStatusQueued) + }) + + t.Run("non-existent queue ID is rejected", func(t *testing.T) { + err := db.UpdateDownlinkResult( + t.Context(), queID+9999, bssci.DLDataResultSent, &txTime, &packetCnt, + bsEUIBytes, epEUIBytes, "100", nil) + assert.ErrorIs(t, err, storage.ErrDownlinkNotFound) + requireStatus(t, bssci.DLQueueStatusQueued) + }) + + t.Run("expired result with nil txTime and packetCnt succeeds", func(t *testing.T) { + err := db.UpdateDownlinkResult( + t.Context(), queID, bssci.DLDataResultExpired, nil, nil, + bsEUIBytes, epEUIBytes, "100", nil) + require.NoError(t, err) + requireStatus(t, bssci.DLQueueStatusExpired) + }) +} + +// TestDownlinkResultConcurrentTenantIsolation verifies concurrent +// UpdateDownlinkResult calls cannot cross tenant boundaries. +func TestDownlinkResultConcurrentTenantIsolation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sqlxDB, cleanup := SetupPostgresContainer(t) + defer cleanup() + + createTestTenant(t, sqlxDB, 100, "TestTenant100") + createTestTenant(t, sqlxDB, 200, "TestTenant200") + + logger.Initialize("error", "json") + log := logger.Get() + db := &DB{conn: sqlxDB.DB, sqlxDB: sqlxDB, log: log} + + epEUI := uint64(0x1234567890ABCDEF) + insertEndpoint(t, sqlxDB, EndpointInsertParams{EpEUI: epEUI, Name: "TestEndpoint-Concurrent", TenantID: 100}) + + epEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(epEUIBytes, epEUI) + bsEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(bsEUIBytes, uint64(0x0123456789ABCDEF)) + + // Ten downlinks alternating between tenants: even indices belong to + // tenant 200, odd indices to tenant 100. + const total = 10 + queIDs := make([]int64, total) + for i := 0; i < total; i++ { + tenantID := int64(100) + if i%2 == 0 { + tenantID = 200 + } + queIDs[i] = insertDownlink(t, db, DownlinkInsertParams{ + EpEUI: epEUI, + TenantID: tenantID, + Status: bssci.DLQueueStatusQueued, + }) + } + + // All goroutines act as tenant 100; updates against tenant 200 rows must + // fail with the not-found sentinel and leave those rows untouched. + var wg sync.WaitGroup + errCh := make(chan error, total) + for i := 0; i < total; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + + txTime := time.Now().UnixNano() + packetCnt := uint32(index) + err := db.UpdateDownlinkResult( + t.Context(), queIDs[index], bssci.DLDataResultSent, &txTime, &packetCnt, + bsEUIBytes, epEUIBytes, "100", nil) + + if index%2 == 0 { + if err == nil { + errCh <- fmt.Errorf("queue %d belongs to tenant 200 but tenant 100 update succeeded", queIDs[index]) + } + } else if err != nil { + errCh <- fmt.Errorf("queue %d belongs to tenant 100 but update failed: %w", queIDs[index], err) + } + }(i) + } + wg.Wait() + close(errCh) + + for err := range errCh { + t.Error(err) + } + + var tenant100Transmitted, tenant200Queued int + require.NoError(t, sqlxDB.QueryRow( + "SELECT COUNT(*) FROM downlink_queue WHERE tenant_id = 100 AND status = $1", + bssci.DLQueueStatusTransmitted).Scan(&tenant100Transmitted)) + require.NoError(t, sqlxDB.QueryRow( + "SELECT COUNT(*) FROM downlink_queue WHERE tenant_id = 200 AND status = $1", + bssci.DLQueueStatusQueued).Scan(&tenant200Queued)) + + assert.Equal(t, total/2, tenant100Transmitted, "all tenant 100 rows must be transmitted") + assert.Equal(t, total/2, tenant200Queued, "all tenant 200 rows must remain queued") +} diff --git a/KC-DB/storage/postgres/downlink_result_test.go.broken b/KC-DB/storage/postgres/downlink_result_test.go.broken deleted file mode 100644 index 7d1bec5..0000000 --- a/KC-DB/storage/postgres/downlink_result_test.go.broken +++ /dev/null @@ -1,355 +0,0 @@ -package postgres - -import ( - "context" - "encoding/binary" - "testing" - "time" - - "github.com/jmoiron/sqlx" - "github.com/kilocenter/KC-DB/storage" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestGetDownlinkResultsWithFilters tests the GetDownlinkResults method with various filters -func TestGetDownlinkResultsWithFilters(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - db := setupTestDB(t) - defer db.Close() - - store := &DB{ - sqlxDB: db, - } - - ctx := context.Background() - tenantID := "1" - epEui := "1234567890ABCDEF" - - // Insert test data - setupDownlinkTestData(t, db, tenantID, epEui) - - tests := []struct { - name string - epEui string - statusFilter string - timeFrom *time.Time - timeTo *time.Time - expectedCount int - expectedStatus []string - }{ - { - name: "no filters", - epEui: "", - statusFilter: "", - timeFrom: nil, - timeTo: nil, - expectedCount: 5, // All records - expectedStatus: []string{"transmitted", "transmitted", "expired", "failed", "queued"}, - }, - { - name: "filter by endpoint", - epEui: epEui, - statusFilter: "", - timeFrom: nil, - timeTo: nil, - expectedCount: 3, // Only records for this endpoint - expectedStatus: []string{"transmitted", "expired", "failed"}, - }, - { - name: "filter by status transmitted", - epEui: "", - statusFilter: "transmitted", - timeFrom: nil, - timeTo: nil, - expectedCount: 2, - expectedStatus: []string{"transmitted", "transmitted"}, - }, - { - name: "filter by endpoint and status", - epEui: epEui, - statusFilter: "transmitted", - timeFrom: nil, - timeTo: nil, - expectedCount: 1, - expectedStatus: []string{"transmitted"}, - }, - { - name: "filter by time range", - epEui: "", - statusFilter: "", - timeFrom: timePtr(time.Now().Add(-2 * time.Hour)), - timeTo: timePtr(time.Now().Add(-30 * time.Minute)), - expectedCount: 2, // Records within time range - expectedStatus: []string{"transmitted", "expired"}, - }, - { - name: "all filters combined", - epEui: epEui, - statusFilter: "transmitted", - timeFrom: timePtr(time.Now().Add(-2 * time.Hour)), - timeTo: timePtr(time.Now()), - expectedCount: 1, - expectedStatus: []string{"transmitted"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - results, totalCount, err := store.GetDownlinkResults( - ctx, - tt.epEui, - tenantID, - tt.statusFilter, - tt.timeFrom, - tt.timeTo, - 10, - 0, - ) - - require.NoError(t, err) - assert.Equal(t, tt.expectedCount, totalCount) - assert.Len(t, results, tt.expectedCount) - - // Check status values - for i, result := range results { - if i < len(tt.expectedStatus) { - assert.Equal(t, tt.expectedStatus[i], result.Status) - } - } - }) - } -} - -// TestGetDownlinkResultsTenantIsolation tests that tenant isolation is enforced -func TestGetDownlinkResultsTenantIsolation(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - db := setupTestDB(t) - defer db.Close() - - store := &DB{ - sqlxDB: db, - } - - ctx := context.Background() - - // Insert data for different tenants - setupMultiTenantDownlinkData(t, db) - - tests := []struct { - name string - tenantID string - expectedCount int - }{ - { - name: "tenant 1 sees only their data", - tenantID: "1", - expectedCount: 3, - }, - { - name: "tenant 2 sees only their data", - tenantID: "2", - expectedCount: 2, - }, - { - name: "tenant 3 sees no data", - tenantID: "3", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - results, totalCount, err := store.GetDownlinkResults( - ctx, - "", // No epEui filter - tt.tenantID, - "", // No status filter - nil, - nil, - 10, - 0, - ) - - require.NoError(t, err) - assert.Equal(t, tt.expectedCount, totalCount) - assert.Len(t, results, tt.expectedCount) - - // Verify all results belong to the correct tenant - for _, result := range results { - assert.Equal(t, tt.tenantID, result.TenantID) - } - }) - } -} - -// TestUpdateDownlinkResultSecurity tests security aspects of UpdateDownlinkResult -func TestUpdateDownlinkResultSecurity(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - db := setupTestDB(t) - defer db.Close() - - store := &DB{ - sqlxDB: db, - } - - ctx := context.Background() - - // Setup: Insert a downlink for tenant 1 - queId := uint64(12345) - epEui := uint64(0x1234567890ABCDEF) - tenantID := int64(1) - - insertDownlink(t, db, queId, epEui, tenantID, "queued") - - tests := []struct { - name string - updateTenantID int64 - expectedError error - checkStatus string - }{ - { - name: "same tenant can update", - updateTenantID: 1, - expectedError: nil, - checkStatus: "transmitted", - }, - { - name: "different tenant cannot update", - updateTenantID: 2, - expectedError: storage.ErrNotFound, // No rows affected - checkStatus: "queued", // Status should not change - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - txTime := time.Now() - packetCnt := uint32(10) - - // UpdateDownlinkResult takes: ctx, queId, result, txTime, packetCnt, bsEui, epEui, tenantID - bsEuiBytes := make([]byte, 8) - epEuiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(bsEuiBytes, 0x0123456789ABCDEF) - binary.BigEndian.PutUint64(epEuiBytes, epEui) - - txTimeNano := txTime.UnixNano() - err := store.UpdateDownlinkResult( - ctx, - int64(queId), - "transmitted", - &txTimeNano, - &packetCnt, - bsEuiBytes, - epEuiBytes, - tt.updateTenantID, - ) - - if tt.expectedError != nil { - assert.ErrorIs(t, err, tt.expectedError) - } else { - assert.NoError(t, err) - } - - // Verify the status in database - var status string - err = db.QueryRow("SELECT status FROM downlink_queue WHERE que_id = $1 AND tenant_id = $2", - queId, tenantID).Scan(&status) - require.NoError(t, err) - assert.Equal(t, tt.checkStatus, status) - - // Reset status for next test - if tt.expectedError == nil { - _, _ = db.Exec("UPDATE downlink_queue SET status = 'pending' WHERE que_id = $1", queId) - } - }) - } -} - -// Helper functions - -func setupTestDB(t *testing.T) *sqlx.DB { - // This assumes a test database is available - // In production, you'd use a test container or mock - db, err := sqlx.Connect("postgres", "postgres://kilocenter:changeme@localhost:5433/kilocenter_test?sslmode=disable") - if err != nil { - t.Skipf("Cannot connect to test database: %v", err) - } - - // Clean up test data from downlink_queue table - _, _ = db.Exec("DELETE FROM downlink_queue WHERE tenant_id IN (1, 2, 3)") - - return db -} - -func setupDownlinkTestData(t *testing.T, db *sqlx.DB, tenantID, epEui string) { - now := time.Now() - - testData := []struct { - queId uint64 - epEui string - status string - txTime time.Time - }{ - {1001, epEui, "transmitted", now.Add(-1 * time.Hour)}, - {1002, epEui, "expired", now.Add(-45 * time.Minute)}, - {1003, epEui, "failed", now.Add(-15 * time.Minute)}, - {1004, "FEDCBA0987654321", "transmitted", now.Add(-1 * time.Hour)}, - {1005, "FEDCBA0987654321", "queued", now}, - } - - for _, td := range testData { - _, err := db.Exec(` - INSERT INTO downlink_queue (que_id, ep_eui, tenant_id, status, transmitted_at, payload, created_at) - VALUES ($1, decode($2, 'hex'), $3, $4, $5, '\\x00', $6) - `, td.queId, td.epEui, tenantID, td.status, td.txTime, now) - require.NoError(t, err) - } -} - -func setupMultiTenantDownlinkData(t *testing.T, db *sqlx.DB) { - now := time.Now() - - testData := []struct { - queId uint64 - tenantID string - status string - }{ - {2001, "1", "transmitted"}, - {2002, "1", "expired"}, - {2003, "1", "failed"}, - {2004, "2", "transmitted"}, - {2005, "2", "queued"}, - } - - for _, td := range testData { - _, err := db.Exec(` - INSERT INTO downlink_queue (que_id, ep_eui, tenant_id, status, payload, created_at) - VALUES ($1, decode('1234567890ABCDEF', 'hex'), $2, $3, '\\x00', $4) - `, td.queId, td.tenantID, td.status, now) - require.NoError(t, err) - } -} - -func insertDownlink(t *testing.T, db *sqlx.DB, queId, epEui uint64, tenantID int64, status string) { - epEuiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(epEuiBytes, epEui) - _, err := db.Exec(` - INSERT INTO downlink_queue (que_id, ep_eui, tenant_id, status, payload, created_at) - VALUES ($1, $2, $3, $4, '\\x00', $5) - `, queId, epEuiBytes, tenantID, status, time.Now()) - require.NoError(t, err) -} - -func timePtr(t time.Time) *time.Time { - return &t -} diff --git a/KC-DB/storage/postgres/eui64_integration_test.go b/KC-DB/storage/postgres/eui64_integration_test.go new file mode 100644 index 0000000..b86cfe9 --- /dev/null +++ b/KC-DB/storage/postgres/eui64_integration_test.go @@ -0,0 +1,133 @@ +package postgres + +import ( + "encoding/binary" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// eui64MatrixValues covers the full unsigned EUI-64 range boundaries, +// including values above INT64_MAX that overflow signed storage. +var eui64MatrixValues = []uint64{ + 0x0000000000000001, + 0x7FFFFFFFFFFFFFFF, + 0x8000000000000000, + 0xCAFECAFECAFECAFE, + 0xFFFFFFFFFFFFFFFF, +} + +func eui64Bytes(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +// TestEUI64BaseStationPersistence verifies base stations with any EUI-64 +// value persist and read back bit-exact through the BYTEA storage path, +// both tenant-scoped and via the global connect-time lookup. +func TestEUI64BaseStationPersistence(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + db, cleanup := SetupPostgresContainer(t) + defer cleanup() + + const tenantID = int64(400) + createTestTenant(t, db, tenantID, "TestTenantEUI64") + + repo := NewBaseStationRepository(db) + ctx := testutil.TestContext() + + for _, eui := range eui64MatrixValues { + var euiArr models.EUI + copy(euiArr[:], eui64Bytes(eui)) + + bs := &models.BaseStation{ + EUI: euiArr, + TenantID: tenantID, + Name: "TestEUI64-BS", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), + } + require.NoError(t, repo.Create(ctx, bs), "EUI %016X must persist", eui) + + stored, err := repo.GetByEUI(ctx, tenantID, euiArr[:]) + require.NoError(t, err, "EUI %016X must read back tenant-scoped", eui) + assert.Equal(t, euiArr, stored.EUI, "EUI %016X must be bit-exact", eui) + + global, err := repo.GetByEUIGlobal(ctx, euiArr[:]) + require.NoError(t, err, "EUI %016X must resolve via connect-time global lookup", eui) + assert.Equal(t, euiArr, global.EUI) + assert.Equal(t, tenantID, global.TenantID) + + require.NoError(t, repo.Delete(ctx, tenantID, stored.ID)) + } +} + +// TestEUI64MessagePersistence verifies uplink messages carrying full-range +// endpoint and base station EUIs survive the message store round-trip. +func TestEUI64MessagePersistence(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + db, cleanup := SetupPostgresContainer(t) + defer cleanup() + + const tenantID = int64(401) + createTestTenant(t, db, tenantID, "TestTenantEUI64Msg") + + for _, eui := range eui64MatrixValues { + received := time.Now() + id := insertArchivalMessage(t, db, archivalMessageParams{ + TenantID: tenantID, + EpEUI: eui, + BsEUI: eui, + ReceivedAt: received, + }) + + var epStored, bsStored []byte + require.NoError(t, db.QueryRow( + `SELECT ep_eui, bs_eui FROM messages WHERE id = $1`, id, + ).Scan(&epStored, &bsStored)) + + assert.Equal(t, eui64Bytes(eui), epStored, "ep_eui %016X must be bit-exact", eui) + assert.Equal(t, eui64Bytes(eui), bsStored, "bs_eui %016X must be bit-exact", eui) + assert.Equal(t, eui, binary.BigEndian.Uint64(epStored), + "ep_eui %016X must recover the exact uint64", eui) + } +} + +// TestEUI64EndpointPersistence verifies endpoints with full-range EUIs +// persist through the endpoint repository BYTEA path. +func TestEUI64EndpointPersistence(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + db, cleanup := SetupPostgresContainer(t) + defer cleanup() + + const tenantID = int64(402) + createTestTenant(t, db, tenantID, "TestTenantEUI64Ep") + + for _, eui := range eui64MatrixValues { + var stored []byte + require.NoError(t, db.QueryRow(` + INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, sh_addr, nwk_sn_key, bidi) + VALUES ($1, $1, $2, $3, $4, $5, true) + RETURNING ep_eui`, + tenantID, eui64Bytes(eui), "TestEUI64-EP", 0x1234, + make([]byte, 16), + ).Scan(&stored), "endpoint EUI %016X must persist", eui) + + assert.Equal(t, eui, binary.BigEndian.Uint64(stored), + "endpoint EUI %016X must be bit-exact", eui) + } +} diff --git a/KC-DB/storage/postgres/fixtures_test.go.broken b/KC-DB/storage/postgres/fixtures_test.go.broken deleted file mode 100644 index a0e09e7..0000000 --- a/KC-DB/storage/postgres/fixtures_test.go.broken +++ /dev/null @@ -1,541 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "encoding/json" - - "github.com/kilocenter/KC-DB/storage/models" -) - -// TestFixtures provides reusable test data creation functions -type TestFixtures struct { - db *sql.DB - ctx context.Context - t *testing.T -} - -// NewTestFixtures creates a new test fixtures instance -func NewTestFixtures(t *testing.T, db *sql.DB) *TestFixtures { - return &TestFixtures{ - db: db, - ctx: context.Background(), - t: t, - } -} - -// CreateTenant creates a test tenant -func (f *TestFixtures) CreateTenant(name string) *models.Tenant { - tenant := &models.Tenant{ - Name: name, - Description: "Test tenant", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - query := ` - INSERT INTO tenants (name, description, created_at, updated_at) - VALUES ($1, $2, $3, $4) - RETURNING id - ` - - err := f.db.QueryRowContext(f.ctx, query, - tenant.Name, - tenant.Description, - tenant.CreatedAt, - tenant.UpdatedAt, - ).Scan(&tenant.ID) - - require.NoError(f.t, err) - return tenant -} - -// CreateDevice creates a test device -func (f *TestFixtures) CreateDevice(tenantID int64, deviceEUI string) *models.Device { - // Parse the EUI string into the EUI type - var eui models.EUI - _, err := fmt.Sscanf(deviceEUI, "%016x", &eui) - require.NoError(f.t, err) - - device := &models.Device{ - EUI: eui, - TenantID: tenantID, - Name: "Test Device " + deviceEUI, - Description: "Test device fixture", - DeviceClass: "A", - NetworkKey: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, - AppKey: []byte{0x10, 0x1f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}, - Tags: map[string]string{"type": "test", "category": "fixture"}, - } - - var id int64 - var createdAt, updatedAt time.Time - err = f.db.QueryRowContext(f.ctx, ` - INSERT INTO devices (eui, tenant_id, name, description, device_class, - network_key, app_key, tags) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, created_at, updated_at - `, device.EUI, device.TenantID, device.Name, device.Description, - device.DeviceClass, device.NetworkKey, device.AppKey, device.Tags). - Scan(&id, &createdAt, &updatedAt) - require.NoError(f.t, err) - - device.ID = id - device.CreatedAt = createdAt - device.UpdatedAt = updatedAt - return device -} - -// CreateGateway creates a test gateway -func (f *TestFixtures) CreateGateway(tenantID int64, gatewayEUI string) *models.Gateway { - // Parse the EUI string into the EUI type - var eui models.EUI - _, err := fmt.Sscanf(gatewayEUI, "%016x", &eui) - require.NoError(f.t, err) - - lat := 52.5200 - lon := 13.4050 - alt := 100.0 - - gateway := &models.Gateway{ - EUI: eui, - TenantID: tenantID, - Name: "Test Gateway " + gatewayEUI, - Description: "Test gateway fixture", - Latitude: &lat, - Longitude: &lon, - Altitude: &alt, - Tags: map[string]string{"type": "test", "category": "fixture"}, - } - - var id int64 - var createdAt, updatedAt time.Time - err = f.db.QueryRowContext(f.ctx, ` - INSERT INTO gateways (eui, tenant_id, name, description, - latitude, longitude, altitude, tags) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, created_at, updated_at - `, gateway.EUI, gateway.TenantID, gateway.Name, gateway.Description, - gateway.Latitude, gateway.Longitude, gateway.Altitude, gateway.Tags). - Scan(&id, &createdAt, &updatedAt) - require.NoError(f.t, err) - - gateway.ID = id - gateway.CreatedAt = createdAt - gateway.UpdatedAt = updatedAt - return gateway -} - -// CreateMessage creates a test message -func (f *TestFixtures) CreateMessage(deviceID int64, tenantID int64) *models.Message { - message := &models.Message{ - DeviceID: fmt.Sprintf("%d", deviceID), - TenantID: fmt.Sprintf("%d", tenantID), - EPEUI: "0102030405060708", - BSEUI: "0807060504030201", - ReceivedAt: time.Now(), - Payload: []byte("test payload"), - PayloadHex: "7465737420706179c2a46f6164", - RSSI: -70.0, - SNR: 10.0, - EqSNR: 8.5, - Frequency: 868300000, - SpreadingFactor: 7, - Bandwidth: 125000, - CodeRate: "4/5", - MessageType: "uplink", - FrameCount: 1, - CryptoMode: "0x00", - DlOpen: false, - ResExp: false, - DlAck: false, - Confirmed: false, - Metadata: json.RawMessage(`{"test": "data"}`), - } - - query := ` - INSERT INTO messages (device_id, tenant_id, epeui, bseui, - received_at, payload, payload_hex, rssi, snr, eq_snr, frequency, - spreading_factor, bandwidth, code_rate, message_type, frame_count, - crypto_mode, dl_open, res_exp, dl_ack, confirmed, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) - RETURNING id, created_at, updated_at - ` - - var id string - var createdAt, updatedAt time.Time - err := f.db.QueryRowContext(f.ctx, query, - message.DeviceID, message.TenantID, message.EPEUI, message.BSEUI, - message.ReceivedAt, message.Payload, message.PayloadHex, message.RSSI, - message.SNR, message.EqSNR, message.Frequency, message.SpreadingFactor, - message.Bandwidth, message.CodeRate, message.MessageType, message.FrameCount, - message.CryptoMode, message.DlOpen, message.ResExp, message.DlAck, - message.Confirmed, message.Metadata). - Scan(&id, &createdAt, &updatedAt) - require.NoError(f.t, err) - - message.ID = id - message.CreatedAt = createdAt - message.UpdatedAt = updatedAt - return message -} - -// CreateGatewayReception creates a test gateway reception -func (f *TestFixtures) CreateGatewayReception(messageID string, gatewayID int64, tenantID int64) *models.GatewayReception { - channel := int32(0) - rssi := float32(-50.0) - snr := float32(5.0) - eqSnr := float32(3.0) - - reception := &models.GatewayReception{ - MessageID: messageID, - GatewayID: fmt.Sprintf("%d", gatewayID), - TenantID: fmt.Sprintf("%d", tenantID), - RSSI: rssi, - SNR: snr, - EqSNR: &eqSnr, - Frequency: 868300000, - Channel: &channel, - ReceivedAt: time.Now(), - } - - query := ` - INSERT INTO gateway_receptions (message_id, gateway_id, tenant_id, - rssi, snr, eq_snr, frequency, channel, received_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, created_at - ` - - var id string - var createdAt time.Time - err := f.db.QueryRowContext(f.ctx, query, - reception.MessageID, reception.GatewayID, reception.TenantID, - reception.RSSI, reception.SNR, reception.EqSNR, - reception.Frequency, reception.Channel, reception.ReceivedAt). - Scan(&id, &createdAt) - require.NoError(f.t, err) - - reception.ID = id - reception.CreatedAt = createdAt - return reception -} - -// CreateDeviceSession creates a test device session -func (f *TestFixtures) CreateDeviceSession(deviceID, tenantID int64) *models.DeviceSession { - sessionKey := []byte("test-session-key-1234567890abcdef") - shAddr := int32(12345) - - session := &models.DeviceSession{ - DeviceID: deviceID, - TenantID: tenantID, - SessionID: "test-session-001", - AttachCnt: 1, - SessionKey: sessionKey, - Status: "active", - StartedAt: time.Now(), - LastActivityAt: time.Now(), - ShAddr: &shAddr, - PacketCnt: 0, - UplinkMode: "standard", - DlOpen: false, - ResExp: false, - DlAck: false, - Repetition: 0, - UplinkCount: 0, - DownlinkCount: 0, - Metadata: json.RawMessage(`{"test": "session"}`), - } - - query := ` - INSERT INTO device_sessions (device_id, tenant_id, session_id, attach_cnt, - session_key, status, started_at, last_activity_at, sh_addr, packet_cnt, - uplink_mode, dl_open, res_exp, dl_ack, repetition, uplink_count, - downlink_count, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) - RETURNING id, created_at, updated_at - ` - - var id int64 - var createdAt, updatedAt time.Time - err := f.db.QueryRowContext(f.ctx, query, - session.DeviceID, session.TenantID, session.SessionID, session.AttachCnt, - session.SessionKey, session.Status, session.StartedAt, session.LastActivityAt, - session.ShAddr, session.PacketCnt, session.UplinkMode, session.DlOpen, - session.ResExp, session.DlAck, session.Repetition, session.UplinkCount, - session.DownlinkCount, session.Metadata). - Scan(&id, &createdAt, &updatedAt) - require.NoError(f.t, err) - - session.ID = id - session.CreatedAt = createdAt - session.UpdatedAt = updatedAt - return session -} - -// CreateDeviceKey creates a test device key -func (f *TestFixtures) CreateDeviceKey(deviceID int64, tenantID int64, keyType string) *models.DeviceKey { - keyValue := []byte("test-key-value-1234567890abcdef") - keyID := "test-key-001" - - key := &models.DeviceKey{ - DeviceID: fmt.Sprintf("%d", deviceID), - TenantID: fmt.Sprintf("%d", tenantID), - KeyType: keyType, - KeyVersion: 1, - KeyValue: keyValue, - KeyID: &keyID, - Algorithm: "AES-128", - ValidFrom: time.Now(), - IsActive: true, - UsageCount: 0, - Metadata: json.RawMessage(`{"test": "key"}`), - } - - query := ` - INSERT INTO device_keys (device_id, tenant_id, key_type, key_version, - key_value, key_id, algorithm, valid_from, is_active, usage_count, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id, created_at - ` - - var id string - var createdAt time.Time - err := f.db.QueryRowContext(f.ctx, query, - key.DeviceID, key.TenantID, key.KeyType, key.KeyVersion, - key.KeyValue, key.KeyID, key.Algorithm, key.ValidFrom, - key.IsActive, key.UsageCount, key.Metadata). - Scan(&id, &createdAt) - require.NoError(f.t, err) - - key.ID = id - key.CreatedAt = createdAt - return key -} - -// CreateGatewaySession creates a test gateway session -func (f *TestFixtures) CreateGatewaySession(gatewayID int64, tenantID int64) *models.GatewaySession { - remoteAddr := "192.168.1.100:5000" - protocolVersion := "BSSCI/1.0" - connectionID := "conn-001" - - session := &models.GatewaySession{ - GatewayID: gatewayID, - TenantID: tenantID, - SessionID: "test-gw-session-001", - ConnectionID: &connectionID, - RemoteAddr: &remoteAddr, - ProtocolVersion: &protocolVersion, - Status: "active", - StartedAt: time.Now(), - LastPingAt: nil, - MessagesReceived: 0, - MessagesSent: 0, - BytesReceived: 0, - BytesSent: 0, - Metadata: json.RawMessage(`{"test": "gateway_session"}`), - } - - query := ` - INSERT INTO gateway_sessions (gateway_id, tenant_id, session_id, - connection_id, remote_addr, protocol_version, status, started_at, - messages_received, messages_sent, bytes_received, bytes_sent, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING id, created_at - ` - - var id int64 - var createdAt time.Time - err := f.db.QueryRowContext(f.ctx, query, - session.GatewayID, session.TenantID, session.SessionID, - session.ConnectionID, session.RemoteAddr, session.ProtocolVersion, - session.Status, session.StartedAt, session.MessagesReceived, - session.MessagesSent, session.BytesReceived, session.BytesSent, - session.Metadata). - Scan(&id, &createdAt) - require.NoError(f.t, err) - - session.ID = id - session.CreatedAt = createdAt - return session -} - -// CreateSystemEvent creates a test system event -func (f *TestFixtures) CreateSystemEvent(tenantID int64, eventType string) *models.SystemEvent { - sourceID := "test-source-001" - - event := &models.SystemEvent{ - TenantID: fmt.Sprintf("%d", tenantID), - EventType: eventType, - Category: "system", - Severity: "info", - SourceType: "system", - SourceID: &sourceID, - Title: "Test Event " + eventType, - Description: "Test system event fixture", - Details: json.RawMessage(`{"test": "event_details"}`), - Status: "new", - Tags: []string{"test", "fixture"}, - Metadata: json.RawMessage(`{"test": "event_metadata"}`), - } - - query := ` - INSERT INTO system_events (tenant_id, event_type, category, severity, - source_type, source_id, title, description, details, status, tags, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id, created_at, updated_at - ` - - var id string - var createdAt, updatedAt time.Time - err := f.db.QueryRowContext(f.ctx, query, - event.TenantID, event.EventType, event.Category, event.Severity, - event.SourceType, event.SourceID, event.Title, event.Description, - event.Details, event.Status, event.Tags, event.Metadata). - Scan(&id, &createdAt, &updatedAt) - require.NoError(f.t, err) - - event.ID = id - event.CreatedAt = createdAt - event.UpdatedAt = updatedAt - return event -} - -// CreateDownlinkMessage creates a test downlink message -func (f *TestFixtures) CreateDownlinkMessage(deviceID, tenantID int64) *models.DownlinkMessage { - // Parse device EUI - var deviceEUI models.EUI - _, err := fmt.Sscanf("0102030405060708", "%016x", &deviceEUI) - require.NoError(f.t, err) - - downlink := &models.DownlinkMessage{ - EPEUI: deviceEUI, - TenantID: tenantID, - Payload: []byte("downlink test data"), - Port: 10, - Confirmed: false, - Priority: 1, // int32 priority - Status: "pending", - MaxAttempts: 3, - Attempts: 0, - EarliestAt: time.Now(), - } - - var id int64 - var createdAt, updatedAt time.Time - err = f.db.QueryRowContext(f.ctx, ` - INSERT INTO downlink_queue (device_eui, tenant_id, payload, port, - confirmed, priority, status, max_attempts, attempts, earliest_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id, created_at, updated_at - `, downlink.EPEUI, downlink.TenantID, downlink.Payload, downlink.Port, - downlink.Confirmed, downlink.Priority, downlink.Status, downlink.MaxAttempts, - downlink.Attempts, downlink.EarliestAt). - Scan(&id, &createdAt, &updatedAt) - require.NoError(f.t, err) - - downlink.ID = id - downlink.CreatedAt = createdAt - downlink.UpdatedAt = updatedAt - return downlink -} - -// CreateCompleteTestScenario creates a complete test scenario with all entities -func (f *TestFixtures) CreateCompleteTestScenario() *TestScenario { - // Create tenant - tenant := f.CreateTenant("Complete Test Tenant") - - // Create devices - device1 := f.CreateDevice(tenant.ID, "0102030405060708") - device2 := f.CreateDevice(tenant.ID, "0807060504030201") - - // Create gateways - gateway1 := f.CreateGateway(tenant.ID, "1122334455667788") - gateway2 := f.CreateGateway(tenant.ID, "8877665544332211") - - // Create messages - message1 := f.CreateMessage(device1.ID, tenant.ID) - message2 := f.CreateMessage(device2.ID, tenant.ID) - - // Create gateway receptions - reception1 := f.CreateGatewayReception(message1.ID, gateway1.ID, tenant.ID) - reception2 := f.CreateGatewayReception(message1.ID, gateway2.ID, tenant.ID) - reception3 := f.CreateGatewayReception(message2.ID, gateway1.ID, tenant.ID) - - // Create device sessions - session1 := f.CreateDeviceSession(device1.ID, tenant.ID) - session2 := f.CreateDeviceSession(device2.ID, tenant.ID) - - // Create device keys - key1 := f.CreateDeviceKey(device1.ID, tenant.ID, "network") - key2 := f.CreateDeviceKey(device1.ID, tenant.ID, "app") - key3 := f.CreateDeviceKey(device2.ID, tenant.ID, "network") - - // Create gateway sessions - gwSession1 := f.CreateGatewaySession(gateway1.ID, tenant.ID) - gwSession2 := f.CreateGatewaySession(gateway2.ID, tenant.ID) - - // Create system events - event1 := f.CreateSystemEvent(tenant.ID, "device_joined") - event2 := f.CreateSystemEvent(tenant.ID, "gateway_connected") - - // Create downlink messages - downlink1 := f.CreateDownlinkMessage(device1.ID, tenant.ID) - downlink2 := f.CreateDownlinkMessage(device2.ID, tenant.ID) - - return &TestScenario{ - Tenant: tenant, - Devices: []*models.Device{device1, device2}, - Gateways: []*models.Gateway{gateway1, gateway2}, - Messages: []*models.Message{message1, message2}, - GatewayReceptions: []*models.GatewayReception{reception1, reception2, reception3}, - DeviceSessions: []*models.DeviceSession{session1, session2}, - DeviceKeys: []*models.DeviceKey{key1, key2, key3}, - GatewaySessions: []*models.GatewaySession{gwSession1, gwSession2}, - SystemEvents: []*models.SystemEvent{event1, event2}, - DownlinkMessages: []*models.DownlinkMessage{downlink1, downlink2}, - } -} - -// TestScenario contains a complete set of test entities -type TestScenario struct { - Tenant *models.Tenant - Devices []*models.Device - Gateways []*models.Gateway - Messages []*models.Message - GatewayReceptions []*models.GatewayReception - DeviceSessions []*models.DeviceSession - DeviceKeys []*models.DeviceKey - GatewaySessions []*models.GatewaySession - SystemEvents []*models.SystemEvent - DownlinkMessages []*models.DownlinkMessage -} - -// CleanupTestData removes all test data created by fixtures -func (f *TestFixtures) CleanupTestData() { - // Clean up in reverse order of dependencies - tables := []string{ - "downlink_queue", - "system_events", - "gateway_sessions", - "device_keys", - "device_sessions", - "gateway_receptions", - "messages", - "gateways", - "devices", - "tenants", - } - - for _, table := range tables { - _, err := f.db.ExecContext(f.ctx, "DELETE FROM "+table) - if err != nil { - f.t.Logf("Warning: failed to clean up %s: %v", table, err) - } - } -} diff --git a/KC-DB/storage/postgres/manufacturer_repository.go b/KC-DB/storage/postgres/manufacturer_repository.go index 5172aa1..ade931b 100644 --- a/KC-DB/storage/postgres/manufacturer_repository.go +++ b/KC-DB/storage/postgres/manufacturer_repository.go @@ -124,7 +124,7 @@ func (r *ManufacturerRepository) List(ctx context.Context, params *models.Manufa } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } diff --git a/KC-DB/storage/postgres/migration_discovery_test.go b/KC-DB/storage/postgres/migration_discovery_test.go index a3aa86f..aa5b886 100644 --- a/KC-DB/storage/postgres/migration_discovery_test.go +++ b/KC-DB/storage/postgres/migration_discovery_test.go @@ -12,8 +12,8 @@ import ( func TestMigrationDiscovery(t *testing.T) { migrations := discoverMigrations(t) - // We have 132 migration files (numbered 1-138 with gaps at 24, 25, 26, 52, 78, 79) - assert.Len(t, migrations, 132, "Should discover exactly 132 migrations") + // We have 133 migration files (numbered 1-139 with gaps at 24, 25, 26, 52, 78, 79) + assert.Len(t, migrations, 133, "Should discover exactly 133 migrations") // Verify migrations are sorted by number for i := 1; i < len(migrations); i++ { @@ -26,7 +26,7 @@ func TestMigrationDiscovery(t *testing.T) { assert.NotEmpty(t, migrations[0].description, "Migration should have description") // Verify last migration - assert.Equal(t, 138, migrations[len(migrations)-1].number, "Last migration should be #138") + assert.Equal(t, 139, migrations[len(migrations)-1].number, "Last migration should be #139") // Verify specific migrations exist expectedMigrations := map[int]string{ @@ -96,6 +96,7 @@ func TestMigrationDiscovery(t *testing.T) { 136: "add_endpoint_blueprint_snapshot", 137: "catalog_ownership", 138: "blueprint_default_unique", + 139: "restore_message_archival", } for num, expectedDesc := range expectedMigrations { diff --git a/KC-DB/storage/postgres/migration_test.go b/KC-DB/storage/postgres/migration_test.go index 5b47e92..0d8b090 100644 --- a/KC-DB/storage/postgres/migration_test.go +++ b/KC-DB/storage/postgres/migration_test.go @@ -440,9 +440,19 @@ func validateKeyStorageSchema(t *testing.T, db *sql.DB) { } func validateSecurityConstraints(t *testing.T, db *sql.DB) { - // Check constraints exist (constraint renamed in migration 084) + // Migration 013 creates endpoints_eui_length; migration 000084 renames it + // to endpoints_ep_eui_length. Accept either name so the validator holds at + // any schema version from 013 onward. constraints := getConstraintList(t, db, "endpoints") - assert.Contains(t, constraints, "endpoints_ep_eui_length") + hasLengthConstraint := false + for _, name := range constraints { + if name == "endpoints_eui_length" || name == "endpoints_ep_eui_length" { + hasLengthConstraint = true + break + } + } + assert.True(t, hasLengthConstraint, + "endpoints must have an EUI length CHECK constraint (endpoints_eui_length pre-084, endpoints_ep_eui_length post-084), got: %v", constraints) // Note: endpoint_keys table has no named constraints // Validation is trigger-based (validate_key_format function from migration 013) diff --git a/KC-DB/storage/postgres/migrations_test.go b/KC-DB/storage/postgres/migrations_test.go index b373f4a..cdf30de 100644 --- a/KC-DB/storage/postgres/migrations_test.go +++ b/KC-DB/storage/postgres/migrations_test.go @@ -282,11 +282,16 @@ func TestMigrationRunner(t *testing.T) { err := RunMigrationsFromPath(db, config, "../../migrations") assert.NoError(t, err) - // Check final version (should be 118 after all migrations) + // Check final version against the highest discovered migration number + migrations := discoverMigrations(t) + require.NotEmpty(t, migrations) + expectedVersion := migrations[len(migrations)-1].number + var version int err = db.QueryRow("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(&version) require.NoError(t, err) - assert.Equal(t, 118, version, "Final migration version should be 118") + assert.Equal(t, expectedVersion, version, + "Final migration version should match the highest migration file") }) } diff --git a/KC-DB/storage/postgres/organization_repository.go b/KC-DB/storage/postgres/organization_repository.go index 0f3b207..137a166 100644 --- a/KC-DB/storage/postgres/organization_repository.go +++ b/KC-DB/storage/postgres/organization_repository.go @@ -66,11 +66,13 @@ func (r *OrganizationRepository) GetOrgByTenantID(ctx context.Context, tenantID return &org, nil } -// UpsertOrg creates or updates organization (Kilo Cloud sync path) -// Attempts update first; if organization doesn't exist, creates it +// UpsertOrg creates or updates organization (Kilo Cloud sync path). +// The organization ID is the global sync identity, so the existence check is +// by org_id alone; the tenant binding of an existing organization is immutable +// and always preserved, so a payload carrying a different tenant can neither +// move the organization nor read anything through this path. func (r *OrganizationRepository) UpsertOrg(ctx context.Context, org *models.Organization) error { - // Check if organization exists - existing, err := r.GetByID(ctx, org.OrgID, org.TenantID) + existing, err := r.getByOrgID(ctx, org.OrgID) if err != nil { // Only create if org truly doesn't exist if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "not found") { @@ -92,6 +94,24 @@ func (r *OrganizationRepository) UpsertOrg(ctx context.Context, org *models.Orga return r.Update(ctx, org.OrgID, org.TenantID, updates) } +// getByOrgID looks an organization up by its global identity for the sync +// upsert path only; tenant-scoped reads go through GetByID. +func (r *OrganizationRepository) getByOrgID(ctx context.Context, orgID uuid.UUID) (*models.Organization, error) { + var org models.Organization + query := ` + SELECT org_id, tenant_id, name, state, created_at, updated_at + FROM organizations + WHERE org_id = $1` + + if err := r.db.GetContext(ctx, &org, query, orgID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, sql.ErrNoRows + } + return nil, fmt.Errorf("get organization by org_id: %w", err) + } + return &org, nil +} + // Create creates a new organization func (r *OrganizationRepository) Create(ctx context.Context, org *models.Organization) error { query := ` diff --git a/KC-DB/storage/postgres/performance_test.go.broken b/KC-DB/storage/postgres/performance_test.go.broken deleted file mode 100644 index 97046a2..0000000 --- a/KC-DB/storage/postgres/performance_test.go.broken +++ /dev/null @@ -1,482 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/hex" - "fmt" - "math/rand" - "strconv" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/kilocenter/KC-Core/pkg/logger" - "github.com/kilocenter/KC-DB/storage/interfaces" - "github.com/kilocenter/KC-DB/storage/models" -) - -// TestPerformanceWithSampleData tests repository and archival performance with realistic data volumes -func TestPerformanceWithSampleData(t *testing.T) { - if testing.Short() { - t.Skip("Skipping performance test") - } - - // Initialize test database - db, cleanup := setupTestDB(t) - defer cleanup() - - // Initialize logger - logger.Initialize("info", "text") - log := logger.Get() - - // Create database wrapper - dbWrapper := &DB{ - conn: db, - log: log, - } - - // Create repositories - deviceRepo := NewDeviceRepository(dbWrapper) - gatewayReceptionRepo := NewGatewayReceptionRepository(dbWrapper) - deviceSessionRepo := NewDeviceSessionRepository(dbWrapper) - deviceKeyRepo := NewDeviceKeyRepository(dbWrapper) - - // Create archival service - archivalService := NewArchivalService(db, log) - - // Test configuration - config := performanceTestConfig{ - numTenants: 5, - devicesPerTenant: 100, - gatewaysPerTenant: 10, - messagesPerDevice: 1000, - sessionsPerDevice: 5, - keysPerDevice: 3, - concurrentWorkers: 10, - messageRetentionDays: 90, - batchSize: 10, - } - - // Run performance tests - t.Run("DataGeneration", func(t *testing.T) { - testDataGeneration(t, config, dbWrapper, deviceRepo, gatewayReceptionRepo, deviceSessionRepo, deviceKeyRepo) - }) - - t.Run("QueryPerformance", func(t *testing.T) { - testQueryPerformance(t, config, db, deviceRepo) - }) - - t.Run("ArchivalPerformance", func(t *testing.T) { - testArchivalPerformance(t, config, db, archivalService) - }) - - t.Run("ConcurrentOperations", func(t *testing.T) { - testConcurrentOperations(t, config, db, deviceRepo, deviceSessionRepo) - }) -} - -type performanceTestConfig struct { - numTenants int - devicesPerTenant int - gatewaysPerTenant int - messagesPerDevice int - sessionsPerDevice int - keysPerDevice int - concurrentWorkers int - messageRetentionDays int - batchSize int -} - -func testDataGeneration(t *testing.T, config performanceTestConfig, db *DB, - deviceRepo interfaces.DeviceRepository, gatewayReceptionRepo interfaces.GatewayReceptionRepository, - deviceSessionRepo interfaces.DeviceSessionRepository, deviceKeyRepo interfaces.DeviceKeyRepository) { - - ctx := context.Background() - - // Get tenant ID - var tenantID int64 - err := db.conn.QueryRow("SELECT id FROM tenants LIMIT 1").Scan(&tenantID) - if err != nil { - // Create a test tenant if none exists - err = db.conn.QueryRow(` - INSERT INTO tenants (name, description, is_active) - VALUES ('PerfTest', 'Performance test tenant', true) - RETURNING id - `).Scan(&tenantID) - require.NoError(t, err) - } - - // Track created entities - var deviceIDs []int64 - var gatewayIDs []int64 - - // Create devices - deviceStart := time.Now() - totalDevices := 0 - for batch := 0; batch < config.batchSize; batch++ { - for i := 0; i < config.devicesPerTenant; i++ { - device := &models.Device{ - EUI: parseEUI(fmt.Sprintf("%016x", rand.Int63())), - TenantID: tenantID, - Name: fmt.Sprintf("PerfDevice%d", i), - Description: "Performance test device", - DeviceClass: randomChoice([]string{"A", "Z"}), - NetworkKey: hexToBytes(fmt.Sprintf("%032x", rand.Int63())), - AppKey: hexToBytes(fmt.Sprintf("%032x", rand.Int63())), - Tags: map[string]string{"type": "perf-test", "batch": fmt.Sprintf("%d", i/10)}, - } - err := deviceRepo.Create(ctx, device) - require.NoError(t, err) - deviceIDs = append(deviceIDs, device.ID) - totalDevices++ - } - } - t.Logf("Created %d devices in %v", totalDevices, time.Since(deviceStart)) - - // Create gateways - gatewayStart := time.Now() - totalGateways := 0 - for i := 0; i < config.gatewaysPerTenant; i++ { - lat := 40.0 + rand.Float64() - lon := -74.0 + rand.Float64() - alt := 100.0 + rand.Float64()*50 - gateway := &models.Gateway{ - EUI: parseEUI(fmt.Sprintf("%016x", rand.Int63())), - TenantID: tenantID, - Name: fmt.Sprintf("PerfGateway%d", i), - Description: "Performance test gateway", - Latitude: &lat, - Longitude: &lon, - Altitude: &alt, - Tags: map[string]string{"type": "perf-test"}, - } - result, err := db.conn.Exec(` - INSERT INTO gateways (eui, tenant_id, name, description, latitude, longitude, altitude) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, gateway.EUI, gateway.TenantID, gateway.Name, gateway.Description, - gateway.Latitude, gateway.Longitude, gateway.Altitude) - require.NoError(t, err) - _ = result // Ignore the result - - // Get the created gateway ID - var gatewayID int64 - err = db.conn.QueryRow("SELECT id FROM gateways WHERE eui = $1", gateway.EUI).Scan(&gatewayID) - require.NoError(t, err) - gatewayIDs = append(gatewayIDs, gatewayID) - totalGateways++ - } - t.Logf("Created %d gateways in %v", totalGateways, time.Since(gatewayStart)) - - // Create messages and gateway receptions - messageStart := time.Now() - totalMessages := 0 - for _, deviceID := range deviceIDs { - for i := 0; i < config.messagesPerDevice; i++ { - // Create message with varying age for archival testing - ageInDays := rand.Intn(180) // Messages up to 180 days old - receivedAt := time.Now().AddDate(0, 0, -ageInDays) - - message := &models.Message{ - DeviceEUI: fmt.Sprintf("%016x", deviceID), // Convert to string - GatewayEUI: fmt.Sprintf("%016x", gatewayIDs[rand.Intn(len(gatewayIDs))]), // Convert to string - TenantID: strconv.FormatInt(tenantID, 10), // Convert to string - Payload: generateRandomPayload(rand.Intn(200) + 50), - FrameCount: uint32(i), // Use uint32 instead of int32 - RSSI: -50.0 - rand.Float64()*50, // Keep as float64 - SNR: 5.0 + rand.Float64()*10, // Keep as float64 - EqSNR: 3.0 + rand.Float64()*8, // Keep as float64 - Frequency: 868300000 + rand.Int63n(200000), // Keep as int64 - MessageType: randomChoice([]string{"uplink", "downlink_ack"}), - DlOpen: rand.Float32() < 0.1, // Use correct field name - ResExp: rand.Float32() < 0.05, - DlAck: rand.Float32() < 0.02, - ReceivedAt: receivedAt, - CreatedAt: receivedAt, - } - - // Insert message directly - var messageID int64 - err := db.conn.QueryRow(` - INSERT INTO messages (device_eui, gateway_eui, tenant_id, payload, frame_count, - rssi, snr, eq_snr, frequency, message_type, dl_open, res_exp, dl_ack, - received_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - RETURNING id - `, message.DeviceEUI, message.GatewayEUI, message.TenantID, message.Payload, message.FrameCount, - message.RSSI, message.SNR, message.EqSNR, message.Frequency, message.MessageType, - message.DlOpen, message.ResExp, message.DlAck, message.ReceivedAt, message.CreatedAt). - Scan(&messageID) - require.NoError(t, err) - - // Create 1-3 gateway receptions per message - numReceptions := rand.Intn(3) + 1 - for j := 0; j < numReceptions; j++ { - gatewayID := gatewayIDs[rand.Intn(len(gatewayIDs))] - antennaID := int32(rand.Intn(8)) - reception := &models.GatewayReception{ - MessageID: strconv.FormatInt(messageID, 10), // Convert to string - GatewayID: strconv.FormatInt(gatewayID, 10), // Convert to string - RSSI: float32(-50.0 - rand.Float64()*50), - SNR: float32(5.0 + rand.Float64()*10), - ReceivedAt: receivedAt, - AntennaID: antennaID, // Use int32 directly, not pointer - } - - err := gatewayReceptionRepo.Create(ctx, reception) - require.NoError(t, err) - } - - totalMessages++ - } - } - t.Logf("Created %d messages with receptions in %v", totalMessages, time.Since(messageStart)) - - // Create device sessions - sessionStart := time.Now() - for _, deviceID := range deviceIDs { - for i := 0; i < config.sessionsPerDevice; i++ { - startedAt := time.Now().AddDate(0, 0, -(i * 30)) // Sessions 30 days apart - lastActivity := startedAt.Add(time.Hour * 24 * time.Duration(rand.Intn(30))) - session := &models.DeviceSession{ - DeviceID: deviceID, - TenantID: tenantID, - SessionID: fmt.Sprintf("session-%d-%d", deviceID, i), - StartedAt: startedAt, - LastActivityAt: lastActivity, - Status: "active", - UplinkCount: int32(rand.Intn(1000)), - DownlinkCount: int32(rand.Intn(100)), - } - - // Set ended time for inactive sessions - if i > 0 { - endedAt := lastActivity.Add(time.Hour) - session.EndedAt = &endedAt - session.Status = "terminated" - } - - err := deviceSessionRepo.Create(ctx, session) - require.NoError(t, err) - } - } - t.Logf("Created %d device sessions in %v", len(deviceIDs)*config.sessionsPerDevice, time.Since(sessionStart)) - - // Create device keys - keyStart := time.Now() - for _, deviceID := range deviceIDs { - for i := 0; i < config.keysPerDevice; i++ { - keyValue := make([]byte, 16) - rand.Read(keyValue) - key := &models.DeviceKey{ - DeviceID: strconv.FormatInt(deviceID, 10), - TenantID: strconv.FormatInt(tenantID, 10), - KeyType: randomChoice([]string{"network", "application", "join"}), - KeyValue: keyValue, // Use byte array - CreatedAt: time.Now().AddDate(0, 0, -(i * 60)), - } - err := deviceKeyRepo.Create(ctx, key) - require.NoError(t, err) - } - } - t.Logf("Created device keys in %v", time.Since(keyStart)) - - // Summary - totalTime := time.Since(time.Now()) - t.Logf("\nPerformance Test Data Generation Summary:") - t.Logf("Total time: %v", totalTime) - t.Logf("Devices: %d (%.2f/sec)", totalDevices, float64(totalDevices)/totalTime.Seconds()) - t.Logf("Gateways: %d (%.2f/sec)", totalGateways, float64(totalGateways)/totalTime.Seconds()) - t.Logf("Messages: %d (%.2f/sec)", totalMessages, float64(totalMessages)/totalTime.Seconds()) -} - -func testQueryPerformance(t *testing.T, config performanceTestConfig, - db *sql.DB, deviceRepo interfaces.DeviceRepository) { - - ctx := context.Background() - - // Get a sample tenant for testing - var tenantID int64 - err := db.QueryRow("SELECT id FROM tenants WHERE name LIKE 'PerfTenant%' LIMIT 1").Scan(&tenantID) - require.NoError(t, err) - - // Test device queries - t.Run("DeviceQueries", func(t *testing.T) { - // Get devices by tenant - start := time.Now() - devices, err := deviceRepo.GetByTenant(ctx, tenantID) - require.NoError(t, err) - t.Logf("Retrieved %d devices by tenant in %v", len(devices), time.Since(start)) - - // Get device by EUI - if len(devices) > 0 { - start = time.Now() - _, err = deviceRepo.Get(ctx, devices[0].EUI) - require.NoError(t, err) - t.Logf("Retrieved device by EUI in %v", time.Since(start)) - } - }) -} - -func testArchivalPerformance(t *testing.T, config performanceTestConfig, db *sql.DB, archivalService *ArchivalService) { - ctx := context.Background() - - // Count messages to archive - var oldMessageCount int64 - cutoffTime := time.Now().AddDate(0, 0, -config.messageRetentionDays) - err := db.QueryRow("SELECT COUNT(*) FROM messages WHERE received_at < $1", cutoffTime).Scan(&oldMessageCount) - require.NoError(t, err) - t.Logf("Found %d messages older than %d days to archive", oldMessageCount, config.messageRetentionDays) - - // Test message archival - start := time.Now() - archivedCount, err := archivalService.ArchiveOldMessages(ctx, time.Duration(config.messageRetentionDays)*24*time.Hour) - require.NoError(t, err) - duration := time.Since(start) - t.Logf("Archived %d messages in %v (%.2f messages/sec)", - archivedCount, duration, float64(archivedCount)/duration.Seconds()) - - // Test gateway reception archival - start = time.Now() - receptionCount, err := archivalService.ArchiveOldGatewayReceptions(ctx, time.Duration(config.messageRetentionDays)*24*time.Hour) - require.NoError(t, err) - t.Logf("Archived %d gateway receptions in %v", receptionCount, time.Since(start)) - - // Test device session archival - start = time.Now() - sessionCount, err := archivalService.ArchiveOldDeviceSessions(ctx, 180*24*time.Hour) // Archive sessions older than 180 days - require.NoError(t, err) - t.Logf("Archived %d device sessions in %v", sessionCount, time.Since(start)) - - // Test device key archival - start = time.Now() - keyCount, err := archivalService.ArchiveOldDeviceKeys(ctx, 365*24*time.Hour) // Archive keys older than 1 year - require.NoError(t, err) - t.Logf("Archived %d device keys in %v", keyCount, time.Since(start)) - - // Verify data integrity after archival - var remainingCount int64 - err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE received_at < $1", cutoffTime).Scan(&remainingCount) - require.NoError(t, err) - assert.Equal(t, int64(0), remainingCount, "All old messages should be archived") - - var archivedTableCount int64 - err = db.QueryRow("SELECT COUNT(*) FROM messages_archive").Scan(&archivedTableCount) - require.NoError(t, err) - t.Logf("Messages archive table contains %d records", archivedTableCount) -} - -func testConcurrentOperations(t *testing.T, config performanceTestConfig, - db *sql.DB, deviceRepo interfaces.DeviceRepository, deviceSessionRepo interfaces.DeviceSessionRepository) { - - ctx := context.Background() - - // Get a sample tenant for testing - var tenantID int64 - err := db.QueryRow("SELECT id FROM tenants WHERE name LIKE 'PerfTenant%' LIMIT 1").Scan(&tenantID) - require.NoError(t, err) - - devices, err := deviceRepo.GetByTenant(ctx, tenantID) - require.NoError(t, err) - require.GreaterOrEqual(t, len(devices), config.concurrentWorkers) - - // Test concurrent reads - t.Run("ConcurrentReads", func(t *testing.T) { - var wg sync.WaitGroup - errors := make(chan error, config.concurrentWorkers*10) - - for i := 0; i < config.concurrentWorkers; i++ { - wg.Add(1) - go func(workerID int) { - defer wg.Done() - - for j := 0; j < 10; j++ { - switch j % 3 { - case 0: - // List devices - _, err := deviceRepo.GetByTenant(ctx, tenantID) - if err != nil { - errors <- fmt.Errorf("worker %d: failed to list devices: %w", workerID, err) - return - } - case 1: - // Get device by EUI (alternate query) - device := devices[rand.Intn(len(devices))] - _, err := deviceRepo.Get(ctx, device.EUI) - if err != nil { - errors <- fmt.Errorf("worker %d: failed to get device by EUI: %w", workerID, err) - return - } - case 2: - // Get device sessions - device := devices[rand.Intn(len(devices))] - sessions, err := deviceSessionRepo.GetByDevice(ctx, strconv.FormatInt(device.ID, 10), 10, 0) - if err != nil { - errors <- fmt.Errorf("worker %d: failed to get device sessions: %w", workerID, err) - return - } - _ = sessions - } - } - }(i) - } - - wg.Wait() - close(errors) - - // Check for errors - for err := range errors { - t.Error(err) - } - }) -} - -// Helper functions -func randomChoice[T any](choices []T) T { - return choices[rand.Intn(len(choices))] -} - -func parseEUI(hexStr string) models.EUI { - var eui models.EUI - fmt.Sscanf(hexStr, "%016x", &eui) - return eui -} - -func hexToBytes(hexStr string) []byte { - bytes, _ := hex.DecodeString(hexStr) - return bytes -} - -func generateRandomPayload(size int) []byte { - payload := make([]byte, size) - rand.Read(payload) - return payload -} - -func createPerfTestTenant(t *testing.T, db *DB, name string) *models.Tenant { - tenant := &models.Tenant{ - Name: name, - Description: "Performance test tenant", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - query := ` - INSERT INTO tenants (name, description, created_at, updated_at) - VALUES ($1, $2, $3, $4) - RETURNING id - ` - - err := db.conn.QueryRowContext(context.Background(), query, - tenant.Name, - tenant.Description, - tenant.CreatedAt, - tenant.UpdatedAt, - ).Scan(&tenant.ID) - - require.NoError(t, err) - return tenant -} diff --git a/KC-DB/storage/postgres/schema_audit_test.go b/KC-DB/storage/postgres/schema_audit_test.go index 042d0be..5454675 100644 --- a/KC-DB/storage/postgres/schema_audit_test.go +++ b/KC-DB/storage/postgres/schema_audit_test.go @@ -3,6 +3,7 @@ package postgres import ( "fmt" "os" + "path/filepath" "strings" "testing" "time" @@ -161,7 +162,8 @@ func TestSchemaBaseline(t *testing.T) { output.WriteString("\n") } - // Write to file + // Write to file (the evidence directory is not tracked in every checkout) + require.NoError(t, os.MkdirAll(filepath.Dir(outputFile), 0o755)) err = os.WriteFile(outputFile, []byte(output.String()), 0644) require.NoError(t, err) diff --git a/KC-DB/storage/postgres/session_encoding_test.go b/KC-DB/storage/postgres/session_encoding_test.go index cbf8343..777b081 100644 --- a/KC-DB/storage/postgres/session_encoding_test.go +++ b/KC-DB/storage/postgres/session_encoding_test.go @@ -294,7 +294,7 @@ func TestUpdateEncoding_PersistsChange(t *testing.T) { assert.Equal(t, bssci.EncodingMessagePack, session.Encoding) // Update encoding to JSON - err = repo.UpdateEncoding(ctx, session.ID, bssci.EncodingJSON) + err = repo.UpdateEncoding(ctx, session.TenantID, session.ID, bssci.EncodingJSON) require.NoError(t, err) // Verify update was persisted diff --git a/KC-DB/storage/postgres/system_events.go b/KC-DB/storage/postgres/system_events.go index 5af4c43..a3bc622 100644 --- a/KC-DB/storage/postgres/system_events.go +++ b/KC-DB/storage/postgres/system_events.go @@ -271,7 +271,7 @@ func (s *SystemEventStore) GetEvents(ctx context.Context, filter interfaces.Syst argNum++ } if filter.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argNum) + query += fmt.Sprintf(" OFFSET $%d", argNum) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, filter.Offset) } @@ -398,7 +398,7 @@ func (s *SystemEventStore) GetActiveAlerts(ctx context.Context, filter interface argNum++ } if filter.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argNum) + query += fmt.Sprintf(" OFFSET $%d", argNum) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, filter.Offset) } @@ -578,7 +578,7 @@ func (s *SystemEventStore) ListSCACIEvents(ctx context.Context, tenantID int64, argIndex++ } - query += fmt.Sprintf(" ORDER BY occurred_at DESC LIMIT $%d OFFSET $%d", argIndex, argIndex+1) + query += fmt.Sprintf(" ORDER BY occurred_at DESC LIMIT $%d OFFSET $%d", argIndex, argIndex+1) //nolint:gosec // G202: appends parameter placeholders, values are bound args = append(args, limit, offset) rows, err := s.db.QueryContext(ctx, query, args...) diff --git a/KC-DB/storage/postgres/table_verification_test.go b/KC-DB/storage/postgres/table_verification_test.go index 02a3883..05146f2 100644 --- a/KC-DB/storage/postgres/table_verification_test.go +++ b/KC-DB/storage/postgres/table_verification_test.go @@ -227,10 +227,10 @@ func TestEndpointsUnsignedConstraintBounds(t *testing.T) { name string query string }{ - {"sh_addr max uint16", `INSERT INTO endpoints (tenant_id, ep_eui, name, sh_addr) VALUES (999999, E'\\x0102030405060701', 'test1', 65535)`}, - {"attach_cnt max uint32", `INSERT INTO endpoints (tenant_id, ep_eui, name, attach_cnt) VALUES (999999, E'\\x0102030405060702', 'test2', 4294967295)`}, - {"packet_cnt max uint32", `INSERT INTO endpoints (tenant_id, ep_eui, name, packet_cnt) VALUES (999999, E'\\x0102030405060703', 'test3', 4294967295)`}, - {"last_packet_cnt max uint32", `INSERT INTO endpoints (tenant_id, ep_eui, name, last_packet_cnt) VALUES (999999, E'\\x0102030405060704', 'test4', 4294967295)`}, + {"sh_addr max uint16", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, sh_addr) VALUES (999999, 999999, E'\\x0102030405060701', 'test1', 65535)`}, + {"attach_cnt max uint32", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, attach_cnt) VALUES (999999, 999999, E'\\x0102030405060702', 'test2', 4294967295)`}, + {"packet_cnt max uint32", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, packet_cnt) VALUES (999999, 999999, E'\\x0102030405060703', 'test3', 4294967295)`}, + {"last_packet_cnt max uint32", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, last_packet_cnt) VALUES (999999, 999999, E'\\x0102030405060704', 'test4', 4294967295)`}, } for _, tc := range validCases { @@ -247,10 +247,10 @@ func TestEndpointsUnsignedConstraintBounds(t *testing.T) { name string query string }{ - {"sh_addr negative", `INSERT INTO endpoints (tenant_id, ep_eui, name, sh_addr) VALUES (999999, E'\\x0102030405060705', 'test5', -1)`}, - {"attach_cnt negative", `INSERT INTO endpoints (tenant_id, ep_eui, name, attach_cnt) VALUES (999999, E'\\x0102030405060706', 'test6', -1)`}, - {"packet_cnt negative", `INSERT INTO endpoints (tenant_id, ep_eui, name, packet_cnt) VALUES (999999, E'\\x0102030405060707', 'test7', -1)`}, - {"last_packet_cnt negative", `INSERT INTO endpoints (tenant_id, ep_eui, name, last_packet_cnt) VALUES (999999, E'\\x0102030405060708', 'test8', -1)`}, + {"sh_addr negative", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, sh_addr) VALUES (999999, 999999, E'\\x0102030405060705', 'test5', -1)`}, + {"attach_cnt negative", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, attach_cnt) VALUES (999999, 999999, E'\\x0102030405060706', 'test6', -1)`}, + {"packet_cnt negative", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, packet_cnt) VALUES (999999, 999999, E'\\x0102030405060707', 'test7', -1)`}, + {"last_packet_cnt negative", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, last_packet_cnt) VALUES (999999, 999999, E'\\x0102030405060708', 'test8', -1)`}, } for _, tc := range negativeCases { @@ -273,10 +273,10 @@ func TestEndpointsUnsignedConstraintBounds(t *testing.T) { name string query string }{ - {"sh_addr exceeds max (65536)", `INSERT INTO endpoints (tenant_id, ep_eui, name, sh_addr) VALUES (999999, E'\\x0102030405060709', 'test9', 65536)`}, - {"attach_cnt exceeds max (4294967296)", `INSERT INTO endpoints (tenant_id, ep_eui, name, attach_cnt) VALUES (999999, E'\\x010203040506070a', 'test10', 4294967296)`}, - {"packet_cnt exceeds max (4294967296)", `INSERT INTO endpoints (tenant_id, ep_eui, name, packet_cnt) VALUES (999999, E'\\x010203040506070b', 'test11', 4294967296)`}, - {"last_packet_cnt exceeds max (4294967296)", `INSERT INTO endpoints (tenant_id, ep_eui, name, last_packet_cnt) VALUES (999999, E'\\x010203040506070c', 'test12', 4294967296)`}, + {"sh_addr exceeds max (65536)", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, sh_addr) VALUES (999999, 999999, E'\\x0102030405060709', 'test9', 65536)`}, + {"attach_cnt exceeds max (4294967296)", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, attach_cnt) VALUES (999999, 999999, E'\\x010203040506070a', 'test10', 4294967296)`}, + {"packet_cnt exceeds max (4294967296)", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, packet_cnt) VALUES (999999, 999999, E'\\x010203040506070b', 'test11', 4294967296)`}, + {"last_packet_cnt exceeds max (4294967296)", `INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, last_packet_cnt) VALUES (999999, 999999, E'\\x010203040506070c', 'test12', 4294967296)`}, } for _, tc := range upperBoundCases { diff --git a/KC-DB/storage/postgres/transaction_blueprint.go b/KC-DB/storage/postgres/transaction_blueprint.go index 5d4ecc6..ac32cc4 100644 --- a/KC-DB/storage/postgres/transaction_blueprint.go +++ b/KC-DB/storage/postgres/transaction_blueprint.go @@ -279,7 +279,7 @@ func (r *transactionalBlueprintRepository) ListByDeviceModel(ctx context.Context } if offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, offset) } @@ -323,7 +323,7 @@ func (r *transactionalBlueprintRepository) List(ctx context.Context, params *mod } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } @@ -371,7 +371,7 @@ func (r *transactionalBlueprintRepository) ListWithModel(ctx context.Context, pa } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } diff --git a/KC-DB/storage/postgres/transaction_device_model.go b/KC-DB/storage/postgres/transaction_device_model.go index 7d239d5..5f20245 100644 --- a/KC-DB/storage/postgres/transaction_device_model.go +++ b/KC-DB/storage/postgres/transaction_device_model.go @@ -189,7 +189,7 @@ func (r *transactionalDeviceModelRepository) ListByManufacturer(ctx context.Cont } if offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, offset) } @@ -245,7 +245,7 @@ func (r *transactionalDeviceModelRepository) List(ctx context.Context, params *m } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } @@ -296,7 +296,7 @@ func (r *transactionalDeviceModelRepository) ListWithManufacturer(ctx context.Co } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } diff --git a/KC-DB/storage/postgres/transaction_manufacturer.go b/KC-DB/storage/postgres/transaction_manufacturer.go index 83fab13..1b077c9 100644 --- a/KC-DB/storage/postgres/transaction_manufacturer.go +++ b/KC-DB/storage/postgres/transaction_manufacturer.go @@ -120,7 +120,7 @@ func (r *transactionalManufacturerRepository) List(ctx context.Context, params * } if params.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argIndex) + query += fmt.Sprintf(" OFFSET $%d", argIndex) //nolint:gosec // G202: appends a parameter placeholder, values are bound args = append(args, params.Offset) } diff --git a/KC-DB/storage/queries/operation_status_queries.go b/KC-DB/storage/queries/operation_status_queries.go index 351caec..f81d7c1 100644 --- a/KC-DB/storage/queries/operation_status_queries.go +++ b/KC-DB/storage/queries/operation_status_queries.go @@ -86,7 +86,7 @@ const ( last_ping_at FROM basestation_sessions WHERE basestation_id = bs.id - AND status IN ('active', 'resumed', 'terminated', 'disconnected') + AND status IN ('active', 'disconnected', 'terminated') ORDER BY started_at DESC LIMIT 1 ) sess ON true diff --git a/KC-Gateway/cmd/gateway/main.go b/KC-Gateway/cmd/gateway/main.go index 418d723..6af3812 100644 --- a/KC-Gateway/cmd/gateway/main.go +++ b/KC-Gateway/cmd/gateway/main.go @@ -45,6 +45,9 @@ import ( "google.golang.org/grpc/status" ) +// gatewayServiceName identifies KC-Gateway as the source of health and system events. +const gatewayServiceName = "kc-gateway" + // unaryMethods enumerates all unary RPCs from service descriptors. // Used to apply per-RPC timeout only to unary calls (not streams). var unaryMethods = func() map[string]bool { @@ -134,7 +137,7 @@ func main() { Enabled: cfg.Monitoring.TracingEnabled, Endpoint: cfg.Monitoring.TracingEndpoint, SampleRate: cfg.Monitoring.TracingSampleRate, - }, "kc-gateway") + }, gatewayServiceName) if err != nil { l.Error("Failed to init tracing", "error", err) } else { @@ -145,7 +148,7 @@ func main() { Enabled: cfg.Monitoring.MetricsEnabled, Port: cfg.Monitoring.MetricsPort, Path: cfg.Monitoring.MetricsPath, - }, "kc-gateway") + }, gatewayServiceName) if err != nil { l.Error("Failed to init metrics", "error", err) } else { @@ -479,7 +482,7 @@ func main() { hostname, _ := os.Hostname() { details, _ := json.Marshal(map[string]interface{}{ - "service": "kc-gateway", + "service": gatewayServiceName, "host": hostname, "ports": map[string]int{"grpc-web": cfg.GRPC.Port, "health": cfg.Gateway.HealthPort}, }) @@ -491,7 +494,7 @@ func main() { Title: fmt.Sprintf(models.EventTitleServiceStartedFmt, "KC-Gateway", hostname), Description: fmt.Sprintf(models.EventDescriptionServiceStartedFmt, "KC-Gateway", "1.0.0", fmt.Sprintf("gRPC-web :%d, Health :%d", cfg.GRPC.Port, cfg.Gateway.HealthPort)), SourceType: models.SourceTypeSystem, - SourceName: "kc-gateway", + SourceName: gatewayServiceName, Details: details, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -513,7 +516,7 @@ func main() { Title: fmt.Sprintf(models.EventTitleServiceStoppedFmt, "KC-Gateway", hostname), Description: fmt.Sprintf(models.EventDescriptionServiceStoppedFmt, "KC-Gateway", "1.0.0"), SourceType: models.SourceTypeSystem, - SourceName: "kc-gateway", + SourceName: gatewayServiceName, CreatedAt: time.Now(), UpdatedAt: time.Now(), }) diff --git a/KC-Gateway/internal/health/handler.go b/KC-Gateway/internal/health/handler.go index 41d6460..ff7500b 100644 --- a/KC-Gateway/internal/health/handler.go +++ b/KC-Gateway/internal/health/handler.go @@ -13,6 +13,9 @@ import ( "google.golang.org/grpc/connectivity" ) +// statusHealthy is the health endpoint status value for a passing check. +const statusHealthy = "healthy" + // Handler provides gateway health check endpoints. type Handler struct { core *grpc.ClientConn @@ -44,7 +47,7 @@ func checkConn(conn *grpc.ClientConn) (string, bool) { // ServeHealth handles the /health endpoint with upstream and breaker state. func (h *Handler) ServeHealth(w http.ResponseWriter, _ *http.Request) { resp := map[string]interface{}{ - "status": "healthy", + "status": statusHealthy, "service": "kc-gateway", "timestamp": time.Now().UTC().Format(time.RFC3339), } @@ -97,7 +100,7 @@ func (h *Handler) ServeReady(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") if ready { w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"healthy"}`)) + _, _ = w.Write([]byte(`{"status":"` + statusHealthy + `"}`)) } else { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"status":"unhealthy"}`)) @@ -115,7 +118,7 @@ func ServeLive(w http.ResponseWriter, _ *http.Request) { func ServePing(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"healthy"}`)) + _, _ = w.Write([]byte(`{"status":"` + statusHealthy + `"}`)) } // StartHealthServer starts the HTTP health endpoint with all standard paths. @@ -134,6 +137,7 @@ func StartHealthServer(ctx context.Context, port int, core, identity *grpc.Clien ReadHeaderTimeout: 5 * time.Second, } + //nolint:gosec // G118: shutdown deliberately uses a fresh context - the parent is already done go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/KC-Gateway/internal/resilience/rate_limiter.go b/KC-Gateway/internal/resilience/rate_limiter.go index d8c85ae..9b6c781 100644 --- a/KC-Gateway/internal/resilience/rate_limiter.go +++ b/KC-Gateway/internal/resilience/rate_limiter.go @@ -13,6 +13,9 @@ import ( "google.golang.org/grpc/status" ) +// registerAccountIdentityMethod is the rate-limited account registration RPC. +const registerAccountIdentityMethod = "/kilocenter.api.v1.IdentityService/RegisterAccount" + // RegistrationRateLimiter provides per-IP rate limiting for sensitive gRPC methods. type RegistrationRateLimiter struct { limiters sync.Map // map[string]*rate.Limiter @@ -29,7 +32,7 @@ func NewRegistrationRateLimiter(cfg config.GatewayRateLimitConfig) *Registration rate: rate.Limit(float64(cfg.RequestsPerMin) / 60.0), burst: cfg.Burst, methods: map[string]bool{ - "/kilocenter.api.v1.IdentityService/RegisterAccount": true, + registerAccountIdentityMethod: true, "/kilocenter.api.v1.KiloCenterService/RegisterAccount": true, }, stopCh: make(chan struct{}), diff --git a/KC-Identity/cmd/identity/health.go b/KC-Identity/cmd/identity/health.go index c98e52b..36df6d7 100644 --- a/KC-Identity/cmd/identity/health.go +++ b/KC-Identity/cmd/identity/health.go @@ -11,6 +11,13 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" ) +// Health endpoint identity and status values. +const ( + identityServiceName = "kc-identity" + healthStatusHealthy = "healthy" + healthKeyHealthy = "healthy" +) + // healthHandler provides health check endpoints for KC-Identity. type healthHandler struct { db *sql.DB @@ -19,8 +26,8 @@ type healthHandler struct { // ServeHTTP handles the /health endpoint. func (h *healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { resp := map[string]interface{}{ - "status": "healthy", - "service": "kc-identity", + "status": healthStatusHealthy, + "service": identityServiceName, "timestamp": time.Now().UTC().Format(time.RFC3339), } @@ -28,13 +35,13 @@ func (h *healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.db != nil { if err := h.db.PingContext(r.Context()); err != nil { resp["status"] = "unhealthy" - resp["database"] = map[string]interface{}{"healthy": false, "error": err.Error()} + resp["database"] = map[string]interface{}{healthKeyHealthy: false, "error": err.Error()} w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(resp) return } - resp["database"] = map[string]interface{}{"healthy": true} + resp["database"] = map[string]interface{}{healthKeyHealthy: true} } w.Header().Set("Content-Type", "application/json") @@ -87,6 +94,7 @@ func startHealthServer(ctx context.Context, port int, db *sql.DB) { IdleTimeout: 120 * time.Second, } + //nolint:gosec // G118: shutdown deliberately uses a fresh context - the parent is already done go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/KC-Identity/cmd/identity/main.go b/KC-Identity/cmd/identity/main.go index 2cb8246..ca33221 100644 --- a/KC-Identity/cmd/identity/main.go +++ b/KC-Identity/cmd/identity/main.go @@ -63,7 +63,7 @@ func main() { Enabled: cfg.Monitoring.TracingEnabled, Endpoint: cfg.Monitoring.TracingEndpoint, SampleRate: cfg.Monitoring.TracingSampleRate, - }, "kc-identity") + }, identityServiceName) if err != nil { log.Error("Failed to init tracing", "error", err) } else { @@ -74,7 +74,7 @@ func main() { Enabled: cfg.Monitoring.MetricsEnabled, Port: cfg.Monitoring.MetricsPort, Path: cfg.Monitoring.MetricsPath, - }, "kc-identity") + }, identityServiceName) if err != nil { log.Error("Failed to init metrics", "error", err) } else { @@ -117,7 +117,7 @@ func main() { Title: fmt.Sprintf(models.EventTitleServiceStartedFmt, "KC-Identity", hostname), Description: fmt.Sprintf(models.EventDescriptionServiceStartedFmt, "KC-Identity", versionInfo.Version, listenInfo), SourceType: models.SourceTypeSystem, - SourceName: "kc-identity", + SourceName: identityServiceName, CreatedAt: time.Now(), UpdatedAt: time.Now(), }) @@ -142,7 +142,7 @@ func main() { Title: fmt.Sprintf(models.EventTitleServiceStoppedFmt, "KC-Identity", hostname), Description: fmt.Sprintf(models.EventDescriptionServiceStoppedFmt, "KC-Identity", versionInfo.Version), SourceType: models.SourceTypeSystem, - SourceName: "kc-identity", + SourceName: identityServiceName, CreatedAt: time.Now(), UpdatedAt: time.Now(), }) diff --git a/KC-Identity/internal/grpc/identity_service_admin.go b/KC-Identity/internal/grpc/identity_service_admin.go index 44acdaf..3aeb175 100644 --- a/KC-Identity/internal/grpc/identity_service_admin.go +++ b/KC-Identity/internal/grpc/identity_service_admin.go @@ -20,6 +20,9 @@ import ( pkgcontext "github.com/Kiloiot/kilo-service-center/pkg/context" ) +// auditKeyOrgID is the audit event detail key for organization identifiers. +const auditKeyOrgID = "orgId" + // validateOrgAccess validates the request org ID and confirms it belongs to the caller's tenant. // Returns the parsed org UUID and tenant ID, or a gRPC status error. func (s *IdentityService) validateOrgAccess(ctx context.Context, reqOrgID string) (uuid.UUID, int64, error) { @@ -144,7 +147,7 @@ func (s *IdentityService) CreateOrganization(ctx context.Context, req *pb.Create Title: models.EventTitleOrgCreated, Description: fmt.Sprintf(models.EventDescriptionOrgCreated, org.Name), SourceName: org.Name, - Details: map[string]any{"orgId": org.OrgID.String(), "name": org.Name}, + Details: map[string]any{auditKeyOrgID: org.OrgID.String(), "name": org.Name}, }) return &pb.CreateOrganizationResponse{ @@ -208,7 +211,7 @@ func (s *IdentityService) UpdateOrganization(ctx context.Context, req *pb.Update Title: models.EventTitleOrgUpdated, Description: fmt.Sprintf(models.EventDescriptionOrgUpdated, org.Name), SourceName: org.Name, - Details: map[string]any{"orgId": org.OrgID.String(), "name": org.Name}, + Details: map[string]any{auditKeyOrgID: org.OrgID.String(), "name": org.Name}, }) return &pb.UpdateOrganizationResponse{ @@ -247,7 +250,7 @@ func (s *IdentityService) DeleteOrganization(ctx context.Context, req *pb.Delete EventType: models.EventTypeOrgDeleted, Title: models.EventTitleOrgDeleted, Description: fmt.Sprintf(models.EventDescriptionOrgDeleted, req.Id), - Details: map[string]any{"orgId": req.Id}, + Details: map[string]any{auditKeyOrgID: req.Id}, }) return &pb.DeleteOrganizationResponse{Success: true}, nil @@ -286,7 +289,7 @@ func apiKeyDeletedAuditEvent(tenantID int64, orgID, keyID uuid.UUID, keyName str Title: models.EventTitleAPIKeyDeleted, Description: fmt.Sprintf(models.EventDescriptionAPIKeyDeletedFmt, keyName), SourceName: keyName, - Details: map[string]any{"keyId": keyID.String(), "orgId": orgID.String()}, + Details: map[string]any{"keyId": keyID.String(), auditKeyOrgID: orgID.String()}, } } @@ -752,7 +755,7 @@ func (s *IdentityService) CreateApiKey(ctx context.Context, req *pb.CreateApiKey } org, err := s.orgSvc.GetByIDUnscoped(ctx, orgID) if err != nil { - s.log.ErrorContext(ctx, "resolve tenant for API key failed", "orgId", orgID.String(), "error", err) + s.log.ErrorContext(ctx, "resolve tenant for API key failed", auditKeyOrgID, orgID.String(), "error", err) return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCreateApiKeyFailed), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenCreateApiKeyFailed)) } @@ -827,10 +830,10 @@ func (s *IdentityService) resolveAPIKeyUserID(ctx context.Context, keyType strin // apiKeyCreatedAuditEvent builds the audit event for a created API key. func apiKeyCreatedAuditEvent(req *pb.CreateApiKeyRequest, resp *grpcservices.APIKeyCreateResponse, orgID uuid.UUID, tenantID int64, userID *uuid.UUID) grpcerrors.AuditEvent { details := map[string]any{ - "keyId": resp.APIKey.ID.String(), - "keyPrefix": resp.APIKey.KeyPrefix, - "keyType": req.KeyType, - "orgId": orgID.String(), + "keyId": resp.APIKey.ID.String(), + "keyPrefix": resp.APIKey.KeyPrefix, + "keyType": req.KeyType, + auditKeyOrgID: orgID.String(), } var auditUserID string if userID != nil { diff --git a/KC-Identity/internal/grpc/identity_service_auth.go b/KC-Identity/internal/grpc/identity_service_auth.go index bb09a47..1de47ba 100644 --- a/KC-Identity/internal/grpc/identity_service_auth.go +++ b/KC-Identity/internal/grpc/identity_service_auth.go @@ -41,7 +41,7 @@ func (s *IdentityService) Login(ctx context.Context, req *pb.LoginRequest) (*pb. result, err := s.authSvc.Login(ctx, req.Email, req.Password) if err != nil { - s.log.ErrorContext(ctx, "login failed", "email", req.Email, "error", err) + s.log.ErrorContext(ctx, "login failed", userFieldEmail, req.Email, "error", err) s.emitSecurityEvent(ctx, models.EventTypeAuthLoginFailed, models.EventTitleAuthLoginFailed, "Login", "invalid credentials for "+req.Email) return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenInvalidCredentials), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenInvalidCredentials)) @@ -280,7 +280,7 @@ func (s *IdentityService) CreateUser(ctx context.Context, req *pb.CreateUserRequ user, err := s.adminUserSvc.Create(ctx, createReq) if err != nil { - s.log.ErrorContext(ctx, "create user failed", "email", req.Email, "error", err) + s.log.ErrorContext(ctx, "create user failed", userFieldEmail, req.Email, "error", err) return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCreateUserFailed), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenCreateUserFailed)) } @@ -296,7 +296,7 @@ func (s *IdentityService) CreateUser(ctx context.Context, req *pb.CreateUserRequ Description: fmt.Sprintf(models.EventDescriptionUserCreated, req.Email), SourceName: req.Email, UserID: user.ID.String(), - Details: map[string]any{"email": req.Email}, + Details: map[string]any{userFieldEmail: req.Email}, }) } diff --git a/KC-Web/src/components/common/SearchField.tsx b/KC-Web/src/components/common/SearchField.tsx index e52d4cb..4e9ec5c 100644 --- a/KC-Web/src/components/common/SearchField.tsx +++ b/KC-Web/src/components/common/SearchField.tsx @@ -13,7 +13,9 @@ import { SearchIcon } from "@theme/icons"; interface SearchFieldProps { placeholder: string; value: string; - onChange: (event: React.ChangeEvent) => void; + onChange: ( + event: React.ChangeEvent, + ) => void; } const SearchField: React.FC = ({ diff --git a/KC-Web/src/hooks/useRealtime.ts b/KC-Web/src/hooks/useRealtime.ts index f4a2a6b..2936883 100644 --- a/KC-Web/src/hooks/useRealtime.ts +++ b/KC-Web/src/hooks/useRealtime.ts @@ -204,7 +204,9 @@ function normalizeEuiForQueryKey(eui: string): string { } /** Per-event-type targeted query keys (device detail/activity/downlink). */ -function targetedInvalidationKeys(event: RealtimeEvent): (readonly unknown[])[] { +function targetedInvalidationKeys( + event: RealtimeEvent, +): (readonly unknown[])[] { if ( event.type === "basestation.online" || event.type === "basestation.offline" @@ -389,7 +391,10 @@ function useCatchUpOnStreamReconnect(): void { flight.pendingInvalidate = setTimeout(() => { flight.pendingInvalidate = null; const now = Date.now(); - if (now - flight.lastInvalidateAt < TIMING_REALTIME_EVENT_STREAM_CATCHUP_MIN_INTERVAL_MS) { + if ( + now - flight.lastInvalidateAt < + TIMING_REALTIME_EVENT_STREAM_CATCHUP_MIN_INTERVAL_MS + ) { return; } flight.lastInvalidateAt = now; diff --git a/KC-Web/src/mappers/base-station.mapper.ts b/KC-Web/src/mappers/base-station.mapper.ts index ff220e3..335eb25 100644 --- a/KC-Web/src/mappers/base-station.mapper.ts +++ b/KC-Web/src/mappers/base-station.mapper.ts @@ -60,10 +60,7 @@ function mapLocationFields(bs: BaseStationAPI): LocationFields { } /** Resolves the detail-only MIOTY health metrics (BSSCI v1.0.0 §3.5.2). */ -function mapHealthStatus( - bs: BaseStationAPI, - isDetail: boolean, -): HealthFields { +function mapHealthStatus(bs: BaseStationAPI, isDetail: boolean): HealthFields { if (!isDetail) { return { systemTime: undefined, diff --git a/KC-Web/src/modules/base-stations/components/BaseStationCertRegenDialog.tsx b/KC-Web/src/modules/base-stations/components/BaseStationCertRegenDialog.tsx index 4b14379..93df18a 100644 --- a/KC-Web/src/modules/base-stations/components/BaseStationCertRegenDialog.tsx +++ b/KC-Web/src/modules/base-stations/components/BaseStationCertRegenDialog.tsx @@ -37,11 +37,7 @@ const BaseStationCertRegenDialog: React.FC = ({ - diff --git a/KC-Web/src/modules/base-stations/components/BaseStationCommissioningDialog.tsx b/KC-Web/src/modules/base-stations/components/BaseStationCommissioningDialog.tsx index f70beca..ce191ad 100644 --- a/KC-Web/src/modules/base-stations/components/BaseStationCommissioningDialog.tsx +++ b/KC-Web/src/modules/base-stations/components/BaseStationCommissioningDialog.tsx @@ -583,11 +583,7 @@ export default function BaseStationCommissioningDialog({ monoSxGetter={getMonoBody2} > {/* Service Center URL with copy */} - + {LABEL_SC_URL_DISPLAY} diff --git a/KC-Web/src/modules/base-stations/components/BaseStationDetails.tsx b/KC-Web/src/modules/base-stations/components/BaseStationDetails.tsx index b7f8885..9d561cf 100644 --- a/KC-Web/src/modules/base-stations/components/BaseStationDetails.tsx +++ b/KC-Web/src/modules/base-stations/components/BaseStationDetails.tsx @@ -116,10 +116,7 @@ const BaseStationDetails: React.FC = ({ > - setEditDialogOpen(true)} - > + setEditDialogOpen(true)}> diff --git a/KC-Web/src/modules/base-stations/components/BaseStationEditDialog.tsx b/KC-Web/src/modules/base-stations/components/BaseStationEditDialog.tsx index 657a2e7..392f5bf 100644 --- a/KC-Web/src/modules/base-stations/components/BaseStationEditDialog.tsx +++ b/KC-Web/src/modules/base-stations/components/BaseStationEditDialog.tsx @@ -2,10 +2,7 @@ import React, { useEffect, useState } from "react"; import type { GenerateCertificateResponse } from "@api-types/api"; import type { BaseStationUI } from "@api-types/api"; -import { - useUpdateBaseStation, - useUpdateBaseStationEui, -} from "@hooks"; +import { useUpdateBaseStation, useUpdateBaseStationEui } from "@hooks"; import { Alert, Box, @@ -23,10 +20,7 @@ import { import { apiService } from "@services/api"; import type { GrpcApiError } from "@services/grpc/client"; -import { - formatEUIWithDashes, - isValidEUI, -} from "@utils/formatters"; +import { formatEUIWithDashes, isValidEUI } from "@utils/formatters"; import { getMonoBody1 } from "@utils/typography"; import { BS_DETAIL_LAYOUT, @@ -316,8 +310,14 @@ const BaseStationEditDialog: React.FC = ({ if (!validateLocation()) return; - const locationData = buildLocationUpdateData(editFormData, baseStationDetails); - const locationChanged = hasLocationChanged(editFormData, baseStationDetails); + const locationData = buildLocationUpdateData( + editFormData, + baseStationDetails, + ); + const locationChanged = hasLocationChanged( + editFormData, + baseStationDetails, + ); if (euiChanged) { updateEuiMutation.mutate( diff --git a/KC-Web/src/modules/base-stations/components/BaseStationInfoPanel.tsx b/KC-Web/src/modules/base-stations/components/BaseStationInfoPanel.tsx index 0692a8e..1380790 100644 --- a/KC-Web/src/modules/base-stations/components/BaseStationInfoPanel.tsx +++ b/KC-Web/src/modules/base-stations/components/BaseStationInfoPanel.tsx @@ -1,13 +1,7 @@ import React from "react"; import type { BaseStationUI } from "@api-types/api"; -import { - Alert, - Box, - Chip, - CircularProgress, - Typography, -} from "@mui/material"; +import { Alert, Box, Chip, CircularProgress, Typography } from "@mui/material"; import { formatDate, diff --git a/KC-Web/src/modules/base-stations/components/BaseStationLocationFields.tsx b/KC-Web/src/modules/base-stations/components/BaseStationLocationFields.tsx index b40be07..fde2d61 100644 --- a/KC-Web/src/modules/base-stations/components/BaseStationLocationFields.tsx +++ b/KC-Web/src/modules/base-stations/components/BaseStationLocationFields.tsx @@ -115,9 +115,7 @@ const BaseStationLocationFields: React.FC = ({ setMapPickerOpen(false); }} initialLat={values.latitude ? parseFloat(values.latitude) : undefined} - initialLng={ - values.longitude ? parseFloat(values.longitude) : undefined - } + initialLng={values.longitude ? parseFloat(values.longitude) : undefined} /> ); diff --git a/KC-Web/src/modules/base-stations/pages/BaseStations.tsx b/KC-Web/src/modules/base-stations/pages/BaseStations.tsx index 4bb45b6..d1647c0 100644 --- a/KC-Web/src/modules/base-stations/pages/BaseStations.tsx +++ b/KC-Web/src/modules/base-stations/pages/BaseStations.tsx @@ -260,7 +260,10 @@ const BaseStations: React.FC = () => { ? baseStations.find((bs) => bs.id === selectedBaseStation) : null; - const stats = useMemo(() => getBaseStationStats(baseStations), [baseStations]); + const stats = useMemo( + () => getBaseStationStats(baseStations), + [baseStations], + ); const { total: totalBaseStations, online: onlineBaseStations, diff --git a/KC-Web/src/modules/blueprints/hooks/useBlueprints.ts b/KC-Web/src/modules/blueprints/hooks/useBlueprints.ts index 262c70f..8e59763 100644 --- a/KC-Web/src/modules/blueprints/hooks/useBlueprints.ts +++ b/KC-Web/src/modules/blueprints/hooks/useBlueprints.ts @@ -90,12 +90,8 @@ export function useUpdateBlueprint() { export function useDeleteBlueprint() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - id, - }: { - id: string; - deviceModelId: string; - }) => api.deleteBlueprint(id), + mutationFn: ({ id }: { id: string; deviceModelId: string }) => + api.deleteBlueprint(id), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: queryKeys.blueprints.list(variables.deviceModelId), diff --git a/KC-Web/src/modules/blueprints/hooks/useDeviceModels.ts b/KC-Web/src/modules/blueprints/hooks/useDeviceModels.ts index 972d384..0f937d6 100644 --- a/KC-Web/src/modules/blueprints/hooks/useDeviceModels.ts +++ b/KC-Web/src/modules/blueprints/hooks/useDeviceModels.ts @@ -74,12 +74,8 @@ export function useUpdateDeviceModel() { export function useDeleteDeviceModel() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - id, - }: { - id: string; - manufacturerId: string; - }) => api.deleteDeviceModel(id), + mutationFn: ({ id }: { id: string; manufacturerId: string }) => + api.deleteDeviceModel(id), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: queryKeys.blueprints.deviceModels(variables.manufacturerId), diff --git a/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx b/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx index ebe8045..a3dd97d 100644 --- a/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx +++ b/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx @@ -24,10 +24,7 @@ import { validateTypeEui, validateUint32Counter, } from "@utils/formatters"; -import { - MIOTY_EUI_REGEX, - MIOTY_KEY_BYTE_LENGTH, -} from "@constants/app"; +import { MIOTY_EUI_REGEX, MIOTY_KEY_BYTE_LENGTH } from "@constants/app"; import { ENDPOINT_FORM } from "@constants/messages"; import { CheckCircleIcon, InfoIcon } from "@theme/icons"; diff --git a/KC-Web/src/modules/endpoints/components/DownlinkTab.tsx b/KC-Web/src/modules/endpoints/components/DownlinkTab.tsx index 9e69828..5a70c94 100644 --- a/KC-Web/src/modules/endpoints/components/DownlinkTab.tsx +++ b/KC-Web/src/modules/endpoints/components/DownlinkTab.tsx @@ -97,12 +97,7 @@ import { VAL_PACKET_CNT_RANGE, VAL_PRIORITY_RANGE, } from "@constants/messages"; -import { - AddIcon, - DeleteIcon, - ExpandMoreIcon, - SendIcon, -} from "@theme/icons"; +import { AddIcon, DeleteIcon, ExpandMoreIcon, SendIcon } from "@theme/icons"; interface DownlinkTabProps { epEui: string; @@ -694,7 +689,9 @@ export function DownlinkTab({ epEui }: DownlinkTabProps) { fontSize: "0.75rem", }} > - {msg.payload ? formatHexPayload(msg.payload) : "(empty)"} + {msg.payload + ? formatHexPayload(msg.payload) + : "(empty)"} diff --git a/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx b/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx index f406986..0ce1110 100644 --- a/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx +++ b/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx @@ -108,9 +108,7 @@ function applyEndpointAddressChanges( } if (form.carrierOffset !== original.carrierOffset) { changes.carrierOffset = - form.carrierOffset !== "" - ? parseInt(form.carrierOffset, 10) - : undefined; + form.carrierOffset !== "" ? parseInt(form.carrierOffset, 10) : undefined; } } diff --git a/KC-Web/src/modules/endpoints/components/EndPointDetails.tsx b/KC-Web/src/modules/endpoints/components/EndPointDetails.tsx index fb46cc1..5238a40 100644 --- a/KC-Web/src/modules/endpoints/components/EndPointDetails.tsx +++ b/KC-Web/src/modules/endpoints/components/EndPointDetails.tsx @@ -106,12 +106,7 @@ const EndpointOverviewPanel: React.FC = ({ onDeleteClick, }) => ( <> - + @@ -169,9 +164,7 @@ const EndpointOverviewPanel: React.FC = ({ = ({ <>

- {ENDPOINT_DETAILS.DIALOG_DELETE_NOTE_PREFIX}{" "} + + {ENDPOINT_DETAILS.DIALOG_DELETE_NOTE_PREFIX} + {" "} {ENDPOINT_DETAILS.DIALOG_DELETE_NOTE} )} diff --git a/KC-Web/src/modules/endpoints/components/EndpointFormFields.tsx b/KC-Web/src/modules/endpoints/components/EndpointFormFields.tsx index beb8e7c..2060c4f 100644 --- a/KC-Web/src/modules/endpoints/components/EndpointFormFields.tsx +++ b/KC-Web/src/modules/endpoints/components/EndpointFormFields.tsx @@ -180,7 +180,9 @@ export const CounterFields: React.FC = ({ value={lastPacketCnt} onChange={onLastPacketCntChange} error={!!errors.lastPacketCnt} - helperText={errors.lastPacketCnt || ENDPOINT_FORM.HELPER_LAST_PACKET_CNT} + helperText={ + errors.lastPacketCnt || ENDPOINT_FORM.HELPER_LAST_PACKET_CNT + } type="number" required={required} inputProps={{ min: 0, max: MIOTY_UINT32_MAX }} diff --git a/KC-Web/src/modules/endpoints/components/EndpointSettingsSection.tsx b/KC-Web/src/modules/endpoints/components/EndpointSettingsSection.tsx index 1c89e00..497a544 100644 --- a/KC-Web/src/modules/endpoints/components/EndpointSettingsSection.tsx +++ b/KC-Web/src/modules/endpoints/components/EndpointSettingsSection.tsx @@ -27,7 +27,9 @@ interface EndpointSettingsSectionProps { lastPacketCnt: string; attachCnt: string; }; - handleChange: (field: string) => (e: React.ChangeEvent) => void; + handleChange: ( + field: string, + ) => (e: React.ChangeEvent) => void; errors: Record; /** Whether counter fields are required (Add dialog = true, Edit = false). */ counterRequired?: boolean; diff --git a/KC-Web/src/modules/endpoints/pages/EndPoints.tsx b/KC-Web/src/modules/endpoints/pages/EndPoints.tsx index 91514d3..6be7fac 100644 --- a/KC-Web/src/modules/endpoints/pages/EndPoints.tsx +++ b/KC-Web/src/modules/endpoints/pages/EndPoints.tsx @@ -50,12 +50,7 @@ interface EndpointsHeaderProps { } const EndpointsHeader: React.FC = ({ onAddClick }) => ( - + {ENDPOINTS_PAGE.TITLE} @@ -79,9 +74,7 @@ const EndpointsStatsCards: React.FC = ({ - + {ENDPOINTS_PAGE.TOTAL_ENDPOINTS} @@ -216,19 +209,14 @@ const EndpointsTableSection: React.FC = ({
- getMonoBody2(theme)} - > + getMonoBody2(theme)}> {endPoint.epEui} diff --git a/KC-Web/src/modules/organizations/pages/OrganizationDetail.tsx b/KC-Web/src/modules/organizations/pages/OrganizationDetail.tsx index b218104..2b813da 100644 --- a/KC-Web/src/modules/organizations/pages/OrganizationDetail.tsx +++ b/KC-Web/src/modules/organizations/pages/OrganizationDetail.tsx @@ -134,9 +134,7 @@ const OrganizationInfoCard: React.FC<{ org: OrganizationUI }> = ({ org }) => ( - - {ORGANIZATIONS_PAGE.DETAILS_TITLE} - + {ORGANIZATIONS_PAGE.DETAILS_TITLE} diff --git a/KC-Web/src/services/api.ts b/KC-Web/src/services/api.ts index 2b1002e..d52a581 100644 --- a/KC-Web/src/services/api.ts +++ b/KC-Web/src/services/api.ts @@ -406,7 +406,9 @@ type GrpcBaseStationMessage = Awaited< >["messages"][number]; /** Maps a gRPC base station message to the UI message shape */ -function mapBaseStationMessageToUI(m: GrpcBaseStationMessage): BaseStationMessageAPI { +function mapBaseStationMessageToUI( + m: GrpcBaseStationMessage, +): BaseStationMessageAPI { return { id: m.id, bsEui: m.bsEui, diff --git a/KC-Web/src/services/grpc/client.ts b/KC-Web/src/services/grpc/client.ts index 35017ff..dfbf18f 100644 --- a/KC-Web/src/services/grpc/client.ts +++ b/KC-Web/src/services/grpc/client.ts @@ -70,9 +70,7 @@ function bytesToString(value: Uint8Array): string { return textDecoder.decode(value); } -function dateToTimestamp( - date: Date, -): google_protobuf_timestamp_pb.Timestamp { +function dateToTimestamp(date: Date): google_protobuf_timestamp_pb.Timestamp { const timestamp = new google_protobuf_timestamp_pb.Timestamp(); timestamp.fromDate(date); return timestamp; @@ -101,7 +99,8 @@ function applyPaginationAndTimeRange( ): void { if (params?.pageSize) request.setPageSize(params.pageSize); if (params?.pageToken) request.setPageToken(params.pageToken); - if (params?.startTime) request.setStartTime(dateToTimestamp(params.startTime)); + if (params?.startTime) + request.setStartTime(dateToTimestamp(params.startTime)); if (params?.endTime) request.setEndTime(dateToTimestamp(params.endTime)); } @@ -634,7 +633,6 @@ function buildUpdateEndpointRequest( return request; } - /** * gRPC-web Client Service * @@ -894,10 +892,7 @@ class GrpcClientService { /** * Login with email and password */ - async login( - email: string, - password: string, - ): Promise { + async login(email: string, password: string): Promise { const request = new pb.LoginRequest(); request.setEmail(email); request.setPassword(password); @@ -1667,10 +1662,7 @@ class GrpcClientService { * Backend resolves the redirect URI from server-side config, so the * client doesn't send one over the wire. */ - async exchangeOIDC( - code: string, - state: string, - ): Promise { + async exchangeOIDC(code: string, state: string): Promise { const request = new pb.ExchangeOIDCRequest(); request.setCode(code); request.setState(state); @@ -1678,7 +1670,10 @@ class GrpcClientService { const response = await this.promisify< pb.ExchangeOIDCRequest, pb.LoginResponse - >(this.client.exchangeOIDC, { requireOrgUser: false, skipRefreshRetry: true })(request); + >(this.client.exchangeOIDC, { + requireOrgUser: false, + skipRefreshRetry: true, + })(request); return extractAuthTokensAndUser( response, @@ -1702,7 +1697,10 @@ class GrpcClientService { const response = await this.promisify< pb.ExchangeOAuth2Request, pb.LoginResponse - >(this.client.exchangeOAuth2, { requireOrgUser: false, skipRefreshRetry: true })(request); + >(this.client.exchangeOAuth2, { + requireOrgUser: false, + skipRefreshRetry: true, + })(request); return extractAuthTokensAndUser( response, @@ -3118,7 +3116,8 @@ class GrpcClientService { request.setFormat(format); if (params?.direction) request.setDirection(params.direction); if (params?.epEui) request.setEpEui(params.epEui); - if (params?.startTime) request.setStartTime(dateToTimestamp(params.startTime)); + if (params?.startTime) + request.setStartTime(dateToTimestamp(params.startTime)); if (params?.endTime) request.setEndTime(dateToTimestamp(params.endTime)); const response = await this.promisify< @@ -3856,7 +3855,10 @@ class GrpcClientService { /** * List device models for a manufacturer */ - async listDeviceModels(manufacturerId: string, isSystem = false): Promise< + async listDeviceModels( + manufacturerId: string, + isSystem = false, + ): Promise< Array<{ id: string; manufacturerId: string; diff --git a/KC-Web/src/services/grpc/core_pb.d.ts b/KC-Web/src/services/grpc/core_pb.d.ts index 2caa837..942678c 100644 --- a/KC-Web/src/services/grpc/core_pb.d.ts +++ b/KC-Web/src/services/grpc/core_pb.d.ts @@ -1725,6 +1725,9 @@ export class BaseStationStatusRequest extends jspb.Message { getBsEui(): number; setBsEui(value: number): void; + getBsEuiHex(): string; + setBsEuiHex(value: string): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): BaseStationStatusRequest.AsObject; static toObject(includeInstance: boolean, msg: BaseStationStatusRequest): BaseStationStatusRequest.AsObject; @@ -1738,6 +1741,7 @@ export class BaseStationStatusRequest extends jspb.Message { export namespace BaseStationStatusRequest { export type AsObject = { bsEui: number, + bsEuiHex: string, } } @@ -1773,6 +1777,9 @@ export class InitiatePingRequest extends jspb.Message { getBsEui(): number; setBsEui(value: number): void; + getBsEuiHex(): string; + setBsEuiHex(value: string): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): InitiatePingRequest.AsObject; static toObject(includeInstance: boolean, msg: InitiatePingRequest): InitiatePingRequest.AsObject; @@ -1786,6 +1793,7 @@ export class InitiatePingRequest extends jspb.Message { export namespace InitiatePingRequest { export type AsObject = { bsEui: number, + bsEuiHex: string, } } diff --git a/KC-Web/src/services/grpc/core_pb.js b/KC-Web/src/services/grpc/core_pb.js index c91535d..3a5db85 100644 --- a/KC-Web/src/services/grpc/core_pb.js +++ b/KC-Web/src/services/grpc/core_pb.js @@ -17145,7 +17145,8 @@ proto.kilocenter.api.v1.BaseStationStatusRequest.prototype.toObject = function(o */ proto.kilocenter.api.v1.BaseStationStatusRequest.toObject = function(includeInstance, msg) { var f, obj = { - bsEui: jspb.Message.getFieldWithDefault(msg, 1, 0) + bsEui: jspb.Message.getFieldWithDefault(msg, 1, 0), + bsEuiHex: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -17186,6 +17187,10 @@ proto.kilocenter.api.v1.BaseStationStatusRequest.deserializeBinaryFromReader = f var value = /** @type {number} */ (reader.readUint64()); msg.setBsEui(value); break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setBsEuiHex(value); + break; default: reader.skipField(); break; @@ -17222,6 +17227,13 @@ proto.kilocenter.api.v1.BaseStationStatusRequest.serializeBinaryToWriter = funct f ); } + f = message.getBsEuiHex(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } }; @@ -17243,6 +17255,24 @@ proto.kilocenter.api.v1.BaseStationStatusRequest.prototype.setBsEui = function(v }; +/** + * optional string bs_eui_hex = 2; + * @return {string} + */ +proto.kilocenter.api.v1.BaseStationStatusRequest.prototype.getBsEuiHex = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * @param {string} value + * @return {!proto.kilocenter.api.v1.BaseStationStatusRequest} returns this + */ +proto.kilocenter.api.v1.BaseStationStatusRequest.prototype.setBsEuiHex = function(value) { + return jspb.Message.setProto3StringField(this, 2, value); +}; + + @@ -17465,7 +17495,8 @@ proto.kilocenter.api.v1.InitiatePingRequest.prototype.toObject = function(opt_in */ proto.kilocenter.api.v1.InitiatePingRequest.toObject = function(includeInstance, msg) { var f, obj = { - bsEui: jspb.Message.getFieldWithDefault(msg, 1, 0) + bsEui: jspb.Message.getFieldWithDefault(msg, 1, 0), + bsEuiHex: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -17506,6 +17537,10 @@ proto.kilocenter.api.v1.InitiatePingRequest.deserializeBinaryFromReader = functi var value = /** @type {number} */ (reader.readUint64()); msg.setBsEui(value); break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setBsEuiHex(value); + break; default: reader.skipField(); break; @@ -17542,6 +17577,13 @@ proto.kilocenter.api.v1.InitiatePingRequest.serializeBinaryToWriter = function(m f ); } + f = message.getBsEuiHex(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } }; @@ -17563,6 +17605,24 @@ proto.kilocenter.api.v1.InitiatePingRequest.prototype.setBsEui = function(value) }; +/** + * optional string bs_eui_hex = 2; + * @return {string} + */ +proto.kilocenter.api.v1.InitiatePingRequest.prototype.getBsEuiHex = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * @param {string} value + * @return {!proto.kilocenter.api.v1.InitiatePingRequest} returns this + */ +proto.kilocenter.api.v1.InitiatePingRequest.prototype.setBsEuiHex = function(value) { + return jspb.Message.setProto3StringField(this, 2, value); +}; + + From 8f852242b1c61f345cfd96dbc44fa877e3a5a691 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:55 +0200 Subject: [PATCH 02/25] fix(db): complete the BYTEA EUI migration across queries, archives and tooling Follow-through for CHIRP-3540 after EUIs moved to BYTEA(8): rename cascades and the statistics join were still assuming the old signed column, the archive rebuild was destructive and could downgrade high-bit EUIs, and blueprint handler literals were not resolving through the error catalog. Also repairs the web lint and vitest gates so the frontend checks run end to end. --- KC-Core/internal/grpc/core_service.go | 18 ++-- .../internal/grpc/core_service_blueprints.go | 22 +++-- .../grpc/core_service_integrations.go | 4 +- .../services/bssci/connection_service.go | 1 - .../services/bssci/downlink_dispatcher.go | 4 +- .../services/bssci/scaci_forwarder.go | 8 +- .../services/bssci/session_service.go | 33 ++++--- .../internal/services/bssci/status_service.go | 4 +- .../internal/services/integrations/service.go | 4 +- KC-Core/internal/services/scaci/dl_service.go | 2 +- KC-Core/pkg/bssci/constants.go | 4 +- KC-Core/pkg/bssci/contracts.go | 16 ++-- KC-Core/pkg/bssci/conversions.go | 5 +- KC-Core/pkg/bssci/tenant_context_test.go | 4 - KC-Core/pkg/grpc/errors_catalog.go | 44 +++++++++ ...0135_convert_message_eui_to_bytea.down.sql | 37 +++++++- .../000139_restore_message_archival.down.sql | 82 +++++++++++------ .../000139_restore_message_archival.up.sql | 91 +++++++++++++++++-- .../interfaces/mioty_downlink_repository.go | 2 +- .../postgres/basestation_repository.go | 51 ++--------- .../postgres/basestation_repository_test.go | 9 +- KC-DB/storage/postgres/endpoint_repository.go | 29 +----- .../postgres/endpoint_repository_test.go | 61 ++++++++++--- .../endpoint_session_migration_test.go | 38 ++++---- .../postgres/eui64_integration_test.go | 59 +++++++++++- KC-DB/storage/postgres/message_repository.go | 21 +++-- .../postgres/message_repository_test.go | 6 +- .../operation_status_repository_test.go | 12 +-- KC-DB/storage/postgres/schema_audit_test.go | 9 +- KC-DB/storage/postgres/test_helpers_test.go | 20 ++-- .../storage/postgres/transaction_downlink.go | 4 +- KC-Web/eslint.config.js | 3 +- KC-Web/src/hooks/useRealtime.ts | 7 +- .../components/EditEndPointDialog.tsx | 3 +- .../organizations/pages/Organizations.tsx | 3 +- .../modules/users/pages/OrganizationUsers.tsx | 3 +- KC-Web/src/modules/users/pages/Users.tsx | 3 +- KC-Web/src/services/grpc/identity_pb.d.ts | 4 + KC-Web/src/services/grpc/identity_pb.js | 32 ++++++- 39 files changed, 513 insertions(+), 249 deletions(-) diff --git a/KC-Core/internal/grpc/core_service.go b/KC-Core/internal/grpc/core_service.go index bbdb540..3b8386c 100644 --- a/KC-Core/internal/grpc/core_service.go +++ b/KC-Core/internal/grpc/core_service.go @@ -14,7 +14,6 @@ import ( "time" "github.com/google/uuid" - "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/fieldmaskpb" @@ -477,7 +476,10 @@ func (s *CoreService) applyBlueprintSnapshot(ctx context.Context, endpoint *mode } raw, marshalErr := buildBlueprintSnapshot(bp) if marshalErr != nil { - return status.Error(codes.Internal, "failed to build blueprint snapshot: "+marshalErr.Error()) + s.log.ErrorContext(ctx, "failed to build blueprint snapshot", + "blueprint_id", bp.ID, "error", marshalErr) + return status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenBlueprintSnapshotBuildFailed), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBlueprintSnapshotBuildFailed)) } dm := bp.DeviceModelID endpoint.DeviceModelID = &dm @@ -2665,10 +2667,8 @@ func (s *CoreService) RequestBaseStationStatus(ctx context.Context, req *pb.Base "bs_eui", bsEui, "tenant_id", tenantID) - bsEuiBytes := make([]byte, 8) - for i := 7; i >= 0; i-- { - bsEuiBytes[i] = byte(bsEui >> uint(8*(7-i))) // #nosec G115 - i is bounded 0-7, no overflow - } + bsEuiArr := mioty.EUI64(bsEui).ToBytes() + bsEuiBytes := bsEuiArr[:] // Verify tenant ownership first bs, err := s.basestationSvc.GetByEUI(ctx, bsEuiBytes, tenantID) @@ -2737,10 +2737,8 @@ func (s *CoreService) InitiatePing(ctx context.Context, req *pb.InitiatePingRequ "bs_eui", bsEui, "tenant_id", tenantID) - bsEuiBytes := make([]byte, 8) - for i := 7; i >= 0; i-- { - bsEuiBytes[i] = byte(bsEui >> uint(8*(7-i))) // #nosec G115 - i is bounded 0-7, no overflow - } + bsEuiArr := mioty.EUI64(bsEui).ToBytes() + bsEuiBytes := bsEuiArr[:] // Verify tenant ownership _, err = s.basestationSvc.GetByEUI(ctx, bsEuiBytes, tenantID) diff --git a/KC-Core/internal/grpc/core_service_blueprints.go b/KC-Core/internal/grpc/core_service_blueprints.go index 1d0e668..30b0fdd 100644 --- a/KC-Core/internal/grpc/core_service_blueprints.go +++ b/KC-Core/internal/grpc/core_service_blueprints.go @@ -10,7 +10,6 @@ import ( "strconv" "github.com/google/uuid" - "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" @@ -329,7 +328,8 @@ func (s *CoreService) CreateDeviceModel(ctx context.Context, req *pb.CreateDevic s.log.ErrorContext(ctx, "create device model failed", "name", req.Name, "error", err) switch { case errors.Is(err, blueprints.ErrOwnershipMismatch): - return nil, status.Error(codes.InvalidArgument, "is_system must match the parent manufacturer's ownership") + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenSystemOwnershipManufacturerMismatch), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenSystemOwnershipManufacturerMismatch)) case errors.Is(err, blueprints.ErrManufacturerNotFound): return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenManufacturerNotFound), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenManufacturerNotFound)) @@ -582,7 +582,8 @@ func (s *CoreService) CreateBlueprint(ctx context.Context, req *pb.CreateBluepri s.log.ErrorContext(ctx, "create blueprint failed", "version", req.Version, "error", err) switch { case errors.Is(err, blueprints.ErrOwnershipMismatch): - return nil, status.Error(codes.InvalidArgument, "is_system must match the parent device model's ownership") + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenSystemOwnershipDeviceModelMismatch), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenSystemOwnershipDeviceModelMismatch)) case errors.Is(err, blueprints.ErrDeviceModelNotFound): return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenDeviceModelNotFound), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenDeviceModelNotFound)) @@ -943,7 +944,8 @@ func (s *CoreService) CreateDeviceModelWithBlueprint(ctx context.Context, req *p s.log.ErrorContext(ctx, "create device model with blueprint failed", "name", req.Name, "error", err) switch { case errors.Is(err, blueprints.ErrOwnershipMismatch): - return nil, status.Error(codes.InvalidArgument, "is_system must match the parent manufacturer's ownership") + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenSystemOwnershipManufacturerMismatch), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenSystemOwnershipManufacturerMismatch)) case errors.Is(err, blueprints.ErrManufacturerNotFound): return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenManufacturerNotFound), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenManufacturerNotFound)) @@ -1002,11 +1004,13 @@ func (s *CoreService) DecodePreview(ctx context.Context, req *pb.DecodePreviewRe result, err = s.blueprintSvc.DecodePreview(ctx, blueprintID, req.Payload, uint8(req.FormatId)) //nolint:gosec // bounds checked above case *pb.DecodePreviewRequest_SpecJson: if len(src.SpecJson) == 0 { - return nil, status.Error(codes.InvalidArgument, "spec_json is empty") + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenSpecJSONEmpty), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenSpecJSONEmpty)) } result, err = s.blueprintSvc.DecodePreviewInline(ctx, src.SpecJson, req.Payload, uint8(req.FormatId)) //nolint:gosec // bounds checked above default: - return nil, status.Error(codes.InvalidArgument, "decode source required: set blueprint_id or spec_json") + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenDecodeSourceRequired), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenDecodeSourceRequired)) } if err != nil { s.log.ErrorContext(ctx, "decode preview failed", "error", err) @@ -1092,7 +1096,8 @@ func (s *CoreService) validateBulkAssignSetDefault(ctx context.Context, blueprin grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBlueprintNotFound)) } if bp.IsSystem { - return status.Error(codes.InvalidArgument, "set_as_default is not allowed for System blueprints") + return status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenSystemBlueprintDefaultNotAllowed), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenSystemBlueprintDefaultNotAllowed)) } return nil } @@ -1107,7 +1112,8 @@ func (s *CoreService) resolveBulkAssignTargets(ctx context.Context, tenantID int return euis, nil } if req.DeviceModelId == "" { - return nil, status.Error(codes.InvalidArgument, "target required: set device_model_id or ep_euis") + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenAssignTargetRequired), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenAssignTargetRequired)) } modelID, parseErr := uuid.Parse(req.DeviceModelId) if parseErr != nil { diff --git a/KC-Core/internal/grpc/core_service_integrations.go b/KC-Core/internal/grpc/core_service_integrations.go index 280d55b..73d10bf 100644 --- a/KC-Core/internal/grpc/core_service_integrations.go +++ b/KC-Core/internal/grpc/core_service_integrations.go @@ -382,9 +382,9 @@ func integrationToProto(integration *models.Integration) *pb.Integration { } // Convert event filter JSON to Struct - if integration.EventFilter != nil { + if integration.EventFilter.Valid { var filterMap map[string]interface{} - if err := json.Unmarshal(integration.EventFilter, &filterMap); err == nil { + if err := json.Unmarshal(integration.EventFilter.Data, &filterMap); err == nil { if filterStruct, err := structpb.NewStruct(filterMap); err == nil { pb.EventFilter = filterStruct } diff --git a/KC-Core/internal/services/bssci/connection_service.go b/KC-Core/internal/services/bssci/connection_service.go index 26419c0..83258d3 100644 --- a/KC-Core/internal/services/bssci/connection_service.go +++ b/KC-Core/internal/services/bssci/connection_service.go @@ -49,7 +49,6 @@ func (c *connectionService) GetBaseStationGlobal(ctx context.Context, eui [8]byt } // RegisterConnection updates basestation connection status via REAL ConnectionManager -// Real path from server.go:967-979 func (c *connectionService) RegisterConnection(ctx context.Context, session *bssci.Session, _ *basestation.BaseStation, mgr *basestation.ConnectionManager) error { // Build connection status matching the real ConnectionStatus struct status := &basestation.ConnectionStatus{ diff --git a/KC-Core/internal/services/bssci/downlink_dispatcher.go b/KC-Core/internal/services/bssci/downlink_dispatcher.go index 7f0d3c3..68a4247 100644 --- a/KC-Core/internal/services/bssci/downlink_dispatcher.go +++ b/KC-Core/internal/services/bssci/downlink_dispatcher.go @@ -133,7 +133,7 @@ func (d *downlinkDispatcher) DispatchIfAvailable( } // 4. Mark sent with transmission metadata - // packetCnt = nil (unknown until dlDataQueCmp returns actual value) + // packetCnt = nil (unknown until the transmission result arrives via dlDataRes, BSSCI 5.14) // Pass nil for orgID since BSSCI dispatcher uses tenant-level isolation txTime := time.Now().UnixNano() if err := tx.MIOTYDownlinks().MarkReservedAsQueued( @@ -142,7 +142,7 @@ func (d *downlinkDispatcher) DispatchIfAvailable( ownerTenantID, session.BaseStationEUI, txTime, - nil, // packetCnt - dlDataQueCmp will update + nil, // packetCnt - set when the dlDataRes transmission result arrives nil, // orgID - BSSCI uses tenant-level isolation ); err != nil { d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherMarkSentFailed, diff --git a/KC-Core/internal/services/bssci/scaci_forwarder.go b/KC-Core/internal/services/bssci/scaci_forwarder.go index 46a093c..029ecf4 100644 --- a/KC-Core/internal/services/bssci/scaci_forwarder.go +++ b/KC-Core/internal/services/bssci/scaci_forwarder.go @@ -26,7 +26,7 @@ type scaciEPStatusBroadcaster interface { } type scaciForwarder struct { - scaciServer scaciBroadcaster // Real interface from server.go:89 + scaciServer scaciBroadcaster mu sync.RWMutex logger logger.Logger } @@ -52,7 +52,7 @@ func (f *scaciForwarder) SetSCACIServer(scaci scaciBroadcaster) { } // BroadcastULData forwards uplink data to SCACI clients -// Delegates to real scaciBroadcaster.BroadcastULData (server.go:39) +// Delegates to real scaciBroadcaster.BroadcastULData func (f *scaciForwarder) BroadcastULData(ctx context.Context, tenantID int64, data *mioty.ULDataMessage) error { f.mu.RLock() scaci := f.scaciServer @@ -63,7 +63,7 @@ func (f *scaciForwarder) BroadcastULData(ctx context.Context, tenantID int64, da return nil } - // Delegate to real SCACI broadcaster (preserves exact behavior from server.go:1884) + // Delegate to real SCACI broadcaster (preserves exact behavior) if err := scaci.BroadcastULData(ctx, tenantID, data); err != nil { f.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToForwardULDataToSCACI, "epEui", data.EpEui, @@ -76,7 +76,7 @@ func (f *scaciForwarder) BroadcastULData(ctx context.Context, tenantID int64, da } // BroadcastDLDataResult forwards downlink results to SCACI clients -// Delegates to real scaciBroadcaster.BroadcastDLDataResult (server.go:40) +// Delegates to real scaciBroadcaster.BroadcastDLDataResult func (f *scaciForwarder) BroadcastDLDataResult(ctx context.Context, tenantID int64, result *mioty.DLDataResult) error { f.mu.RLock() scaci := f.scaciServer diff --git a/KC-Core/internal/services/bssci/session_service.go b/KC-Core/internal/services/bssci/session_service.go index d059b75..01fc780 100644 --- a/KC-Core/internal/services/bssci/session_service.go +++ b/KC-Core/internal/services/bssci/session_service.go @@ -19,7 +19,7 @@ import ( ) type sessionService struct { - sessionsByUUID map[string]*bssci.Session // REAL map from server.go:84 + sessionsByUUID map[string]*bssci.Session // REAL map bsSessionRepo interfaces.BaseStationSessionRepository // Repository interface for session persistence bsRepo interfaces.BaseStationRepository // Base station repository systemEventStore interfaces.SystemEventStore // System event store (injected separately, not from storage) @@ -182,8 +182,7 @@ func (s *sessionService) hydrateSessionFromDB(dbSession *models.BaseStationSessi } // PersistSession writes to basestation_sessions table using repository interface -// Refactored from raw SQL (server.go:852-950) to use BaseStationSessionRepository -// TODO: Full implementation pending - repository methods need to support ON CONFLICT logic +// Refactored from raw SQL to use BaseStationSessionRepository func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Session, baseStation *basestation.BaseStation, isResume bool, connectInfo json.RawMessage) error { // BSSCI §3.3.1: Connect info overwritten on each resume (user decision) // Only update connect_info when provided - avoid NULL overwrites @@ -212,13 +211,13 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess if err == nil && dbSession != nil { // Terminate stale session before creating new one if err := s.bsSessionRepo.TerminateSession(ctx, s.resolvedTenantID(session), dbSession.ID); err != nil { - s.logger.Error(bssci.LogBSSCIFailedToTerminateStaleSession, + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToTerminateStaleSession, "error", err, "staleSessionID", dbSession.ID, "baseStationEui", session.BaseStationEUI) // Continue with new session creation despite termination error } else { - s.logger.Info(bssci.LogBSSCITerminatedStaleSession, + s.logger.InfoContext(ctx, bssci.LogBSSCITerminatedStaleSession, "staleSessionID", dbSession.ID, "baseStationEui", session.BaseStationEUI) } @@ -303,7 +302,7 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess SnScOpId: &zero, } if err = s.bsSessionRepo.UpdateSession(ctx, s.resolvedTenantID(session), dbSession.ID, updateReq); err != nil { - s.logger.Error(bssci.LogBSSCIFailedToUpdateDatabaseSession, + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateDatabaseSession, "error", err, "sessionID", session.DbSessionID) return fmt.Errorf("failed to initialize session counters: %w", err) @@ -311,7 +310,7 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess // Convert bs.EUI ([8]byte) to uint64 for logging hexEUI := binary.BigEndian.Uint64(baseStation.EUI[:]) - s.logger.Info(bssci.LogBSSCIDatabaseSessionCreated, + s.logger.InfoContext(ctx, bssci.LogBSSCIDatabaseSessionCreated, "sessionID", session.DbSessionID, "bsEui", fmt.Sprintf("%016X", hexEUI)) @@ -324,7 +323,7 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess dbSession, err := s.bsSessionRepo.GetSessionByScUUID(ctx, s.resolvedTenantID(session), scUUID) if err != nil { - s.logger.Error(bssci.LogBSSCIFailedToUpdateDatabaseSession, + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateDatabaseSession, "error", err, "baseStationEui", session.BaseStationEUI) return err @@ -340,7 +339,7 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess session.ConnectInfo = dbSession.ConnectInfo.Data } - s.logger.Info(bssci.LogBSSCIDatabaseSessionUpdated, + s.logger.InfoContext(ctx, bssci.LogBSSCIDatabaseSessionUpdated, "dbSessionID", session.DbSessionID, "baseStationEui", session.BaseStationEUI, "restoredScOpId", session.LastScOpId, @@ -354,7 +353,7 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess session.Encoding = dbSession.Encoding } else { // Invalid encoding in DB - fall back to BSSCI spec default (MessagePack) - s.logger.Warn(bssci.LogBSSCIInvalidEncodingInDatabase, + s.logger.WarnContext(ctx, bssci.LogBSSCIInvalidEncodingInDatabase, "dbEncoding", dbSession.Encoding, "sessionID", dbSession.ID) session.Encoding = bssci.EncodingMessagePack @@ -394,14 +393,14 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess err = s.bsSessionRepo.UpdateSession(ctx, s.resolvedTenantID(session), dbSession.ID, updateReq) if err != nil { - s.logger.Error(bssci.LogBSSCIFailedToUpdateDatabaseSession, + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateDatabaseSession, "error", err, "baseStationEui", session.BaseStationEUI) return err } session.DbSessionID = dbSession.ID - s.logger.Info(bssci.LogBSSCIDatabaseSessionUpdated, + s.logger.InfoContext(ctx, bssci.LogBSSCIDatabaseSessionUpdated, "dbSessionID", session.DbSessionID, "baseStationEui", session.BaseStationEUI) } @@ -410,7 +409,7 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess } // StoreSessionByUUID adds session to sessionsByUUID map -// Real map storage from server.go:760-764 +// Real map storage func (s *sessionService) StoreSessionByUUID(session *bssci.Session) { s.mu.Lock() defer s.mu.Unlock() @@ -420,7 +419,7 @@ func (s *sessionService) StoreSessionByUUID(session *bssci.Session) { } // MarkHandshakeComplete sets HandshakeComplete=true -// Real handshake marker from server.go:1004 +// Real handshake marker func (s *sessionService) MarkHandshakeComplete(session *bssci.Session) { // BSSCI-3.3-03: Mark handshake as complete BEFORE reissuing any pending operations // This allows resumed operations to proceed without being blocked @@ -443,7 +442,7 @@ func (s *sessionService) MarkDisconnected(ctx context.Context, session *bssci.Se } // RemoveSession cleans sessionsByUUID map on disconnect -// Real cleanup from server.go:438 +// Real cleanup func (s *sessionService) RemoveSession(session *bssci.Session) { s.mu.Lock() defer s.mu.Unlock() @@ -478,7 +477,7 @@ func (s *sessionService) UpdateSessionCounters(ctx context.Context, session *bss err := s.bsSessionRepo.UpdateSession(ctx, s.resolvedTenantID(session), session.DbSessionID, updateReq) if err != nil { - s.logger.Error(bssci.LogBSSCIFailedToUpdateDatabaseSession, + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateDatabaseSession, "error", err, "sessionID", session.DbSessionID, "bsOpId", session.LastBsOpId, @@ -522,7 +521,7 @@ func (s *sessionService) TerminateSession(ctx context.Context, session *bssci.Se err := s.bsSessionRepo.TerminateSession(ctx, s.resolvedTenantID(session), session.DbSessionID) if err != nil { - s.logger.Error(bssci.LogBSSCIFailedToTerminateSession, + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToTerminateSession, "error", err, "sessionID", session.DbSessionID, "eui", session.BaseStationEUI) diff --git a/KC-Core/internal/services/bssci/status_service.go b/KC-Core/internal/services/bssci/status_service.go index ee33bf3..a7d4cdf 100644 --- a/KC-Core/internal/services/bssci/status_service.go +++ b/KC-Core/internal/services/bssci/status_service.go @@ -35,7 +35,7 @@ func NewStatusService(pendingOps *map[bssci.SessionOpKey]*bssci.PendingOperation } // RecordPendingOperation stores operation in map + DB using SessionOpKey composite key -// Real map from server.go:135, DB table: bssci_pending_operations +// Persists to DB table bssci_pending_operations and mirrors into the shared cache func (s *statusService) RecordPendingOperation(ctx context.Context, session *bssci.Session, opId int64, op *bssci.PendingOperation, dbSessionID int64) error { // 1. Update in-memory cache using SessionOpKey composite key key := bssci.SessionOpKey{ @@ -50,7 +50,7 @@ func (s *statusService) RecordPendingOperation(ctx context.Context, session *bss // Marshal Message map → operation_data JSON operationData, err := json.Marshal(op.Message) if err != nil { - s.logger.Error("Failed to marshal pending operation", + s.logger.ErrorContext(ctx, "Failed to marshal pending operation", "error", err, "opId", opId) return err diff --git a/KC-Core/internal/services/integrations/service.go b/KC-Core/internal/services/integrations/service.go index 57f6a7e..0fe3d0b 100644 --- a/KC-Core/internal/services/integrations/service.go +++ b/KC-Core/internal/services/integrations/service.go @@ -53,7 +53,7 @@ func (s *Service) Create(ctx context.Context, tenantID int64, req *grpcservices. Name: req.Name, Type: req.Type, Config: req.Config, - EventFilter: req.EventFilter, + EventFilter: models.NullJSON{Valid: req.EventFilter != nil, Data: req.EventFilter}, DeliveryFormat: deliveryFormat, Status: models.IntegrationStatusActive, } @@ -111,7 +111,7 @@ func (s *Service) Update(ctx context.Context, tenantID int64, id int64, req *grp integration.Config = req.Config } if req.EventFilter != nil { - integration.EventFilter = req.EventFilter + integration.EventFilter = models.NullJSON{Valid: true, Data: req.EventFilter} } if req.Status != nil { if !isValidStatus(*req.Status) { diff --git a/KC-Core/internal/services/scaci/dl_service.go b/KC-Core/internal/services/scaci/dl_service.go index 89aa898..4d2b2a8 100644 --- a/KC-Core/internal/services/scaci/dl_service.go +++ b/KC-Core/internal/services/scaci/dl_service.go @@ -116,7 +116,7 @@ func (dls *dlService) QueueDownlink( return 0, 0, scaci.ErrSchedulerUnavailable } - dls.logger.Debug(scaci.LogSCACIDLQueueServiceInvoked, + dls.logger.DebugContext(ctx, scaci.LogSCACIDLQueueServiceInvoked, "queId", req.QueId, "epEui", req.EpEui, "tenantId", tenantID) diff --git a/KC-Core/pkg/bssci/constants.go b/KC-Core/pkg/bssci/constants.go index 4f80692..c09c240 100644 --- a/KC-Core/pkg/bssci/constants.go +++ b/KC-Core/pkg/bssci/constants.go @@ -294,9 +294,9 @@ const ( DLQueueStatusScheduled = "scheduled" // Scheduler selected for transmission DLQueueStatusReserved = "reserved" // Reserved for auto-dispatch (transient, within transaction) DLQueueStatusQueued = "queued" // Sent to BS via dlDataQue, awaiting transmission - DLQueueStatusTransmitted = "transmitted" // BS transmitted downlink (dlDataQueCmp success) + DLQueueStatusTransmitted = "transmitted" // BS reported successful transmission via dlDataRes (BSSCI 5.14) DLQueueStatusDelivered = "delivered" // Endpoint acknowledged receipt (if ack requested) - DLQueueStatusFailed = "failed" // Transmission failed (dlDataQueCmp error) + DLQueueStatusFailed = "failed" // BS reported transmission failure via dlDataRes (BSSCI 5.14) DLQueueStatusExpired = "expired" // Validity period elapsed before transmission DLQueueStatusRevoked = "revoked" // Revoked via dlDataRev before transmission DLQueueStatusAcked = "acked" // Endpoint acknowledgment received (dlDataRes) diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index 121490a..e4be2c7 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -20,7 +20,7 @@ type VersionNegotiator interface { Negotiate(ctx context.Context, requested string) (selected string, err error) } -// SessionService handles connect/resume with REAL persistence (server.go:611-1004) +// SessionService handles connect/resume with REAL persistence // Preserves: sessionsByUUID map, DbSessionId, HandshakeComplete, DB persistence type SessionService interface { // HandleResume validates a session resume request (BSSCI §5.3.1): resume @@ -29,18 +29,18 @@ type SessionService interface { // is not asserted. Returns the resumable session or nil for a fresh start. HandleResume(ctx context.Context, session *Session, bsUUID []byte, bsOpId, scOpId *int64, bsEUI uint64) (*Session, error) - // PersistSession writes to basestation_sessions table (server.go:852-950) + // PersistSession writes to basestation_sessions table // Uses real *sql.DB, updates DbSessionId, handles resume UPDATE vs new INSERT // BSSCI §5.3: Accepts connectInfo to persist arbitrary key-value pairs from connect message PersistSession(ctx context.Context, session *Session, baseStation *basestation.BaseStation, isResume bool, connectInfo json.RawMessage) error - // StoreSessionByUUID adds to sessionsByUUID map (server.go:760-764) + // StoreSessionByUUID adds to sessionsByUUID map StoreSessionByUUID(session *Session) - // MarkHandshakeComplete sets HandshakeComplete=true (server.go:1004) + // MarkHandshakeComplete sets HandshakeComplete=true MarkHandshakeComplete(session *Session) - // RemoveSession cleans sessionsByUUID map on disconnect (server.go:438) + // RemoveSession cleans sessionsByUUID map on disconnect RemoveSession(session *Session) // TerminateSession marks a session as terminated in the database @@ -126,13 +126,13 @@ type ConnectionService interface { RegisterConnection(ctx context.Context, session *Session, baseStation *basestation.BaseStation, mgr *basestation.ConnectionManager) error } -// SCACIBroadcaster forwards via real scaciBroadcaster interface (server.go:39-40) +// SCACIBroadcaster forwards via real scaciBroadcaster interface // Matches the exact signatures from scaciBroadcaster to enable proper delegation type SCACIBroadcaster interface { - // BroadcastULData forwards uplink data to SCACI clients (server.go:39) + // BroadcastULData forwards uplink data to SCACI clients BroadcastULData(ctx context.Context, tenantID int64, data *mioty.ULDataMessage) error - // BroadcastDLDataResult forwards downlink results to SCACI clients (server.go:40) + // BroadcastDLDataResult forwards downlink results to SCACI clients BroadcastDLDataResult(ctx context.Context, tenantID int64, result *mioty.DLDataResult) error } diff --git a/KC-Core/pkg/bssci/conversions.go b/KC-Core/pkg/bssci/conversions.go index 13d80f5..ee265a0 100644 --- a/KC-Core/pkg/bssci/conversions.go +++ b/KC-Core/pkg/bssci/conversions.go @@ -78,9 +78,10 @@ func parseVersionComponent(s string) (int, bool) { return 0, false } } - v, err := strconv.ParseUint(s, 10, 31) + // Digits already validated above; Atoi rejects overflow of the native int. + v, err := strconv.Atoi(s) if err != nil { return 0, false } - return int(v), true + return v, true } diff --git a/KC-Core/pkg/bssci/tenant_context_test.go b/KC-Core/pkg/bssci/tenant_context_test.go index c4f31bf..c73d594 100644 --- a/KC-Core/pkg/bssci/tenant_context_test.go +++ b/KC-Core/pkg/bssci/tenant_context_test.go @@ -16,10 +16,6 @@ import ( // stubSessionService is a no-op session service for smoke tests type stubSessionService struct{} -func (s *stubSessionService) ValidateVersion(_ string) error { - return nil // No-op for smoke tests -} - func (s *stubSessionService) HandleResume(_ context.Context, _ *Session, _ []byte, _, _ *int64, _ uint64) (*Session, error) { return nil, nil // No-op for smoke tests } diff --git a/KC-Core/pkg/grpc/errors_catalog.go b/KC-Core/pkg/grpc/errors_catalog.go index ce9bef8..e593e61 100644 --- a/KC-Core/pkg/grpc/errors_catalog.go +++ b/KC-Core/pkg/grpc/errors_catalog.go @@ -168,6 +168,15 @@ const ( ErrTokenPreviewInvalidPayload = "KC-GRPC-ERR-06D" // Payload bytes failed validation/parsing ErrTokenInvalidModelCode = "KC-GRPC-ERR-06E" // Model code format validation failed + // Blueprint ownership/decode errors (KC-GRPC-ERR-0A3 to KC-GRPC-ERR-0A9) + ErrTokenSystemOwnershipManufacturerMismatch = "KC-GRPC-ERR-0A3" // is_system flag conflicts with parent manufacturer ownership + ErrTokenSystemOwnershipDeviceModelMismatch = "KC-GRPC-ERR-0A4" // is_system flag conflicts with parent device model ownership + ErrTokenSpecJSONEmpty = "KC-GRPC-ERR-0A5" // Decode request carried an empty spec_json + ErrTokenDecodeSourceRequired = "KC-GRPC-ERR-0A6" // Decode request needs blueprint_id or spec_json + ErrTokenSystemBlueprintDefaultNotAllowed = "KC-GRPC-ERR-0A7" // System blueprints cannot be set as tenant defaults + ErrTokenAssignTargetRequired = "KC-GRPC-ERR-0A8" // Assign request needs device_model_id or ep_euis + ErrTokenBlueprintSnapshotBuildFailed = "KC-GRPC-ERR-0A9" // Blueprint snapshot serialization failed + // Integration operation errors (KC-GRPC-ERR-400 to KC-GRPC-ERR-410) // Integration CRUD tokens ErrTokenCreateIntegrationFailed = "KC-GRPC-ERR-400" @@ -1053,6 +1062,41 @@ var errorCatalog = map[string]ErrorDefinition{ Message: "invalid model code: must contain only lowercase alphanumeric characters and hyphens", Code: codes.InvalidArgument, }, + ErrTokenSystemOwnershipManufacturerMismatch: { + Token: ErrTokenSystemOwnershipManufacturerMismatch, + Message: "is_system must match the parent manufacturer's ownership", + Code: codes.InvalidArgument, + }, + ErrTokenSystemOwnershipDeviceModelMismatch: { + Token: ErrTokenSystemOwnershipDeviceModelMismatch, + Message: "is_system must match the parent device model's ownership", + Code: codes.InvalidArgument, + }, + ErrTokenSpecJSONEmpty: { + Token: ErrTokenSpecJSONEmpty, + Message: "spec_json is empty", + Code: codes.InvalidArgument, + }, + ErrTokenDecodeSourceRequired: { + Token: ErrTokenDecodeSourceRequired, + Message: "decode source required: set blueprint_id or spec_json", + Code: codes.InvalidArgument, + }, + ErrTokenSystemBlueprintDefaultNotAllowed: { + Token: ErrTokenSystemBlueprintDefaultNotAllowed, + Message: "set_as_default is not allowed for System blueprints", + Code: codes.InvalidArgument, + }, + ErrTokenAssignTargetRequired: { + Token: ErrTokenAssignTargetRequired, + Message: "target required: set device_model_id or ep_euis", + Code: codes.InvalidArgument, + }, + ErrTokenBlueprintSnapshotBuildFailed: { + Token: ErrTokenBlueprintSnapshotBuildFailed, + Message: "failed to build blueprint snapshot", + Code: codes.Internal, + }, // Integration operation errors ErrTokenCreateIntegrationFailed: { diff --git a/KC-DB/migrations/000135_convert_message_eui_to_bytea.down.sql b/KC-DB/migrations/000135_convert_message_eui_to_bytea.down.sql index 4fb1abd..6369047 100644 --- a/KC-DB/migrations/000135_convert_message_eui_to_bytea.down.sql +++ b/KC-DB/migrations/000135_convert_message_eui_to_bytea.down.sql @@ -1,5 +1,40 @@ -- Revert 000135: EUI columns BYTEA(8) -> BIGINT. --- Rows with the high bit set can't fit signed BIGINT and will fail this revert (expected). +-- Rows with the high bit set cannot be represented in signed BIGINT: the +-- bit(64)::bigint cast would silently reinterpret them as negative values +-- (corruption) and the ep_eui/bs_eui CHECK would then fail opaquely. The +-- precondition below fails the downgrade with a named diagnostic BEFORE any +-- column is altered. + +DO $$ +DECLARE + high_bit_rows BIGINT := 0; + archive_rows BIGINT := 0; +BEGIN + SELECT COUNT(*) INTO high_bit_rows + FROM messages + WHERE get_byte(ep_eui, 0) >= 128 OR get_byte(bs_eui, 0) >= 128; + + SELECT COUNT(*) + high_bit_rows INTO high_bit_rows + FROM endpoints + WHERE last_attached_bs_eui IS NOT NULL + AND get_byte(last_attached_bs_eui, 0) >= 128; + + IF to_regclass('messages_archive') IS NOT NULL THEN + BEGIN + EXECUTE 'SELECT COUNT(*) FROM messages_archive + WHERE get_byte(ep_eui, 0) >= 128 OR get_byte(bs_eui, 0) >= 128' + INTO archive_rows; + EXCEPTION WHEN undefined_column THEN + -- Legacy archive layouts without BYTEA EUI columns have nothing to guard. + archive_rows := 0; + END; + high_bit_rows := high_bit_rows + archive_rows; + END IF; + + IF high_bit_rows > 0 THEN + RAISE EXCEPTION 'KC-MIG-000135-DOWN: % row(s) carry high-bit EUI64 values that cannot fit signed BIGINT; downgrade would corrupt them. Remove or export these rows first.', high_bit_rows; + END IF; +END $$; ALTER TABLE messages DROP CONSTRAINT IF EXISTS valid_euis; diff --git a/KC-DB/migrations/000139_restore_message_archival.down.sql b/KC-DB/migrations/000139_restore_message_archival.down.sql index db4d29e..08e5933 100644 --- a/KC-DB/migrations/000139_restore_message_archival.down.sql +++ b/KC-DB/migrations/000139_restore_message_archival.down.sql @@ -1,40 +1,66 @@ -- Revert to the pre-000139 state: messages without archival columns and the -- pre-000047 messages_archive layout inherited from migrations 001/014. +-- +-- Non-destructive: a corrected archive holding rows blocks the downgrade with a +-- named diagnostic instead of silently destroying archived data. A legacy +-- archive preserved by the up migration (messages_archive_pre000139) is +-- restored by rename; otherwise the legacy layout is recreated empty. -DROP TRIGGER IF EXISTS set_messages_archive_timestamp ON messages_archive; -DROP TABLE IF EXISTS messages_archive; +DO $$ +DECLARE + archived_rows BIGINT; +BEGIN + IF to_regclass('messages_archive') IS NOT NULL THEN + EXECUTE 'SELECT COUNT(*) FROM messages_archive' INTO archived_rows; + IF archived_rows > 0 THEN + RAISE EXCEPTION 'KC-MIG-000139-DOWN: messages_archive holds % row(s); downgrade would destroy archived messages. Export or migrate them first.', archived_rows; + END IF; + EXECUTE 'DROP TRIGGER IF EXISTS set_messages_archive_timestamp ON messages_archive'; + EXECUTE 'DROP TABLE messages_archive'; + END IF; +END $$; DROP INDEX IF EXISTS idx_messages_archived; ALTER TABLE messages DROP COLUMN IF EXISTS archived; ALTER TABLE messages DROP COLUMN IF EXISTS archived_at; --- Recreate the legacy archive layout (001-era messages columns plus the +-- Restore the preserved legacy archive if the up migration kept one; otherwise +-- recreate the legacy layout (001-era messages columns plus the -- archived/archived_at columns that migration 014 relied on). -CREATE TABLE messages_archive ( - id BIGINT NOT NULL, - ep_eui BYTEA NOT NULL CHECK (length(ep_eui) = 8), - bs_eui BYTEA NOT NULL CHECK (length(bs_eui) = 8), - tenant_id BIGINT NOT NULL, - payload BYTEA NOT NULL, - frame_count INTEGER NOT NULL, - rssi REAL NOT NULL, - snr REAL NOT NULL, - eq_snr REAL NOT NULL, - frequency DOUBLE PRECISION NOT NULL, - uplink_mode VARCHAR(10) DEFAULT 'standard', - packet_counter INTEGER, - dl_open BOOLEAN DEFAULT false, - res_exp BOOLEAN DEFAULT false, - dl_ack BOOLEAN DEFAULT false, - is_roaming BOOLEAN DEFAULT false, - home_network_id VARCHAR(50), - roaming_agreement_id BIGINT, - received_at TIMESTAMP WITH TIME ZONE NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - archived BOOLEAN DEFAULT false, - archived_at TIMESTAMP WITH TIME ZONE, - PRIMARY KEY (id, received_at) -); +DO $$ +BEGIN + IF to_regclass('messages_archive_pre000139') IS NOT NULL THEN + EXECUTE 'ALTER TABLE messages_archive_pre000139 RENAME TO messages_archive'; + RAISE NOTICE 'KC-MIG-000139-DOWN: restored preserved legacy archive'; + RETURN; + END IF; + + CREATE TABLE messages_archive ( + id BIGINT NOT NULL, + ep_eui BYTEA NOT NULL CHECK (length(ep_eui) = 8), + bs_eui BYTEA NOT NULL CHECK (length(bs_eui) = 8), + tenant_id BIGINT NOT NULL, + payload BYTEA NOT NULL, + frame_count INTEGER NOT NULL, + rssi REAL NOT NULL, + snr REAL NOT NULL, + eq_snr REAL NOT NULL, + frequency DOUBLE PRECISION NOT NULL, + uplink_mode VARCHAR(10) DEFAULT 'standard', + packet_counter INTEGER, + dl_open BOOLEAN DEFAULT false, + res_exp BOOLEAN DEFAULT false, + dl_ack BOOLEAN DEFAULT false, + is_roaming BOOLEAN DEFAULT false, + home_network_id VARCHAR(50), + roaming_agreement_id BIGINT, + received_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + archived BOOLEAN DEFAULT false, + archived_at TIMESTAMP WITH TIME ZONE, + PRIMARY KEY (id, received_at) + ); +END $$; CREATE INDEX IF NOT EXISTS idx_messages_archive_ep_eui ON messages_archive(ep_eui); CREATE INDEX IF NOT EXISTS idx_messages_archive_tenant_id ON messages_archive(tenant_id); diff --git a/KC-DB/migrations/000139_restore_message_archival.up.sql b/KC-DB/migrations/000139_restore_message_archival.up.sql index a3d14f2..02dbbf7 100644 --- a/KC-DB/migrations/000139_restore_message_archival.up.sql +++ b/KC-DB/migrations/000139_restore_message_archival.up.sql @@ -1,7 +1,15 @@ -- Migration 000139: restore message archival columns and rebuild messages_archive. -- Migration 000047 rebuilt the messages table without the archived/archived_at --- columns while messages_archive kept the pre-000047 column layout, so the --- archival copy from messages into messages_archive failed at runtime. +-- columns while messages_archive kept the pre-000047 (001-era) column layout, so +-- the archival copy from messages into messages_archive failed at runtime. +-- +-- Non-destructive rebuild: the new archive is created under a temporary name with +-- an explicit column list mirroring the live messages table, then swapped in. +-- A legacy archive that still holds rows is PRESERVED under +-- messages_archive_pre000139 -- its 001-era layout (BIGINT id, payload/frame_count) +-- cannot be losslessly projected into the 000047-era shape, so rows are kept +-- queryable in place rather than re-identified. An empty legacy archive is dropped +-- only after its row count is proven zero. -- Restore archival tracking columns on the live table. ALTER TABLE messages ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT false; @@ -10,14 +18,81 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZO -- Partial index used by the archival sweep and the purge of archived rows. CREATE INDEX IF NOT EXISTS idx_messages_archived ON messages(archived, received_at) WHERE archived = false; --- Rebuild the archive table so its column layout matches the live table. -DROP TABLE IF EXISTS messages_archive; -CREATE TABLE messages_archive ( - LIKE messages INCLUDING DEFAULTS INCLUDING CONSTRAINTS +-- Build the corrected archive under a temporary name with an explicit column +-- list (kept in sync with the messages layout as of this migration). +CREATE TABLE messages_archive_new_000139 ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + tenant_id BIGINT NOT NULL, + command_type VARCHAR(20) NOT NULL DEFAULT 'ulData', + op_id BIGINT NOT NULL, + ep_eui BYTEA NOT NULL, + bs_eui BYTEA NOT NULL, + rx_time BIGINT NOT NULL, + packet_cnt INTEGER NOT NULL, + snr DOUBLE PRECISION NOT NULL, + rssi DOUBLE PRECISION NOT NULL, + user_data BYTEA, + dl_open BOOLEAN NOT NULL DEFAULT false, + response_exp BOOLEAN NOT NULL DEFAULT false, + dl_ack BOOLEAN NOT NULL DEFAULT false, + rx_duration BIGINT, + eq_snr DOUBLE PRECISION, + profile VARCHAR(20), + mode VARCHAR(20), + format SMALLINT DEFAULT 0, + subpackets JSONB, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + org_uuid UUID, + owner_tenant_id BIGINT, + base_stations JSONB, + duplicate BOOLEAN NOT NULL DEFAULT false, + decoded_payload JSONB, + blueprint_type_eui BYTEA, + blueprint_version_id UUID, + decode_status VARCHAR(32) DEFAULT 'pending', + decode_error_code VARCHAR(64), + nwk_sn_key BYTEA, + archived BOOLEAN NOT NULL DEFAULT false, + archived_at TIMESTAMPTZ, + CONSTRAINT messages_archive_new_000139_pkey PRIMARY KEY (id), + CONSTRAINT messages_archive_valid_euis CHECK (length(ep_eui) = 8 AND length(bs_eui) = 8), + CONSTRAINT messages_archive_valid_tenant_id CHECK (tenant_id > 0), + CONSTRAINT messages_archive_valid_owner_tenant_id CHECK (owner_tenant_id IS NULL OR owner_tenant_id > 0), + CONSTRAINT messages_archive_valid_packet_cnt CHECK (packet_cnt >= 0), + CONSTRAINT messages_archive_valid_format CHECK (format >= 0 AND format <= 255), + CONSTRAINT messages_archive_blueprint_type_eui_check CHECK (blueprint_type_eui IS NULL OR length(blueprint_type_eui) = 8) ); --- Primary key keeps the copy-if-absent archival insert deduplicated. -ALTER TABLE messages_archive ADD PRIMARY KEY (id); +-- Preserve or retire the legacy archive, then swap the corrected table in. +DO $$ +DECLARE + legacy_rows BIGINT; +BEGIN + IF to_regclass('messages_archive') IS NOT NULL THEN + EXECUTE 'SELECT COUNT(*) FROM messages_archive' INTO legacy_rows; + IF legacy_rows > 0 THEN + -- 001-era rows cannot be losslessly projected into the 000047-era + -- shape; keep them queryable under a preserved name. Free the + -- canonical index name so the corrected table can claim it. + EXECUTE 'DROP TRIGGER IF EXISTS set_messages_archive_timestamp ON messages_archive'; + EXECUTE 'ALTER TABLE messages_archive RENAME TO messages_archive_pre000139'; + IF to_regclass('messages_archive_pkey') IS NOT NULL THEN + EXECUTE 'ALTER INDEX messages_archive_pkey RENAME TO messages_archive_pre000139_pkey'; + END IF; + RAISE NOTICE 'KC-MIG-000139: preserved % legacy archive row(s) in messages_archive_pre000139', legacy_rows; + ELSE + -- Proven empty: nothing to preserve. + EXECUTE 'DROP TABLE messages_archive'; + RAISE NOTICE 'KC-MIG-000139: legacy archive was empty; dropped'; + END IF; + END IF; + + EXECUTE 'ALTER TABLE messages_archive_new_000139 RENAME TO messages_archive'; + EXECUTE 'ALTER INDEX messages_archive_new_000139_pkey RENAME TO messages_archive_pkey'; +END $$; -- Query indexes for the archive table (names follow migration 014). CREATE INDEX idx_messages_archive_ep_eui ON messages_archive(ep_eui); diff --git a/KC-DB/storage/interfaces/mioty_downlink_repository.go b/KC-DB/storage/interfaces/mioty_downlink_repository.go index e573e79..78c5429 100644 --- a/KC-DB/storage/interfaces/mioty_downlink_repository.go +++ b/KC-DB/storage/interfaces/mioty_downlink_repository.go @@ -43,7 +43,7 @@ type MIOTYDownlinkRepository interface { // MarkReservedAsQueued transitions reserved → queued with transmission metadata. // Sets status='queued', transmission_time, tx_bs_eui. transmission_result stays NULL. - // packetCnt is nullable - pass nil if unknown (dlDataQueCmp will update later). + // packetCnt is nullable - pass nil if unknown (set from the dlDataRes transmission result, BSSCI 5.14). // orgID filters by organization; nil = no org filter (backward compatible) MarkReservedAsQueued(ctx context.Context, queID uint64, tenantID int64, bsEUI uint64, txTime int64, packetCnt *uint32, orgID *uuid.UUID) error } diff --git a/KC-DB/storage/postgres/basestation_repository.go b/KC-DB/storage/postgres/basestation_repository.go index e7ee689..c8a9d75 100644 --- a/KC-DB/storage/postgres/basestation_repository.go +++ b/KC-DB/storage/postgres/basestation_repository.go @@ -3,7 +3,6 @@ package postgres import ( "context" "database/sql" - "encoding/binary" "encoding/json" "fmt" "log" @@ -448,14 +447,6 @@ func (r *BaseStationRepository) GetStatistics(ctx context.Context, tenantID int6 return &stats, nil } -// euiToUint64 converts 8-byte EUI to uint64 for BIGINT columns in messages tables -func euiToUint64(eui []byte) uint64 { - if len(eui) != 8 { - return 0 - } - return binary.BigEndian.Uint64(eui) -} - // UpdateEUI updates the Base Station EUI with transactional cascade to all dependent tables func (r *BaseStationRepository) UpdateEUI(ctx context.Context, tenantID int64, oldEui, newEui []byte) (*models.BaseStation, error) { // Validate EUI lengths @@ -494,10 +485,6 @@ func (r *BaseStationRepository) UpdateEUI(ctx context.Context, tenantID int64, o return nil, fmt.Errorf("get base station: %w", err) } - // Calculate integer representations for BIGINT columns - oldUint64 := euiToUint64(oldEui) - newUint64 := euiToUint64(newEui) - // 3. Update all tables with bs_eui reference (transactional cascade) // BYTEA columns: basestations.bs_eui (primary) @@ -545,38 +532,16 @@ func (r *BaseStationRepository) UpdateEUI(ctx context.Context, tenantID int64, o return nil, fmt.Errorf("update mioty_basestation_status: %w", err) } - // Schema-aware update: messages.bs_eui can be BYTEA (migration 001) or BIGINT (migration 047) - var messagesColType string - err = tx.GetContext(ctx, &messagesColType, ` - SELECT data_type - FROM information_schema.columns - WHERE table_name = 'messages' AND column_name = 'bs_eui' - `) + // BYTEA columns: messages.bs_eui (8-byte big-endian per migration 000135) + _, err = tx.ExecContext(ctx, "UPDATE messages SET bs_eui = $1 WHERE bs_eui = $2", newEui, oldEui) if err != nil { - return nil, fmt.Errorf("detect messages column type: %w", err) + return nil, fmt.Errorf("update messages: %w", err) } - if messagesColType == "bytea" { - // BYTEA columns: use raw bytes - _, err = tx.ExecContext(ctx, "UPDATE messages SET bs_eui = $1 WHERE bs_eui = $2", newEui, oldEui) - if err != nil { - return nil, fmt.Errorf("update messages (bytea): %w", err) - } - _, err = tx.ExecContext(ctx, "UPDATE messages_archive SET bs_eui = $1 WHERE bs_eui = $2", newEui, oldEui) - if err != nil { - return nil, fmt.Errorf("update messages_archive (bytea): %w", err) - } - } else { - // BIGINT columns (uint64 conversion): messages.bs_eui - _, err = tx.ExecContext(ctx, "UPDATE messages SET bs_eui = $1 WHERE bs_eui = $2", newUint64, oldUint64) - if err != nil { - return nil, fmt.Errorf("update messages: %w", err) - } - // BIGINT columns (uint64 conversion): messages_archive.bs_eui - _, err = tx.ExecContext(ctx, "UPDATE messages_archive SET bs_eui = $1 WHERE bs_eui = $2", newUint64, oldUint64) - if err != nil { - return nil, fmt.Errorf("update messages_archive: %w", err) - } + // BYTEA columns: messages_archive.bs_eui (rebuilt LIKE messages by migration 000139) + _, err = tx.ExecContext(ctx, "UPDATE messages_archive SET bs_eui = $1 WHERE bs_eui = $2", newEui, oldEui) + if err != nil { + return nil, fmt.Errorf("update messages_archive: %w", err) } // BYTEA columns: endpoints.last_attached_bs_eui @@ -629,7 +594,7 @@ func (r *BaseStationRepository) ListWithStats(ctx context.Context, tenantID int6 FROM messages WHERE tenant_id = $1 GROUP BY bs_eui - ) m ON ('x' || encode(b.bs_eui, 'hex'))::bit(64)::bigint = m.bs_eui + ) m ON b.bs_eui = m.bs_eui WHERE b.tenant_id = $1 ORDER BY b.last_seen_at DESC NULLS LAST LIMIT $2 OFFSET $3 diff --git a/KC-DB/storage/postgres/basestation_repository_test.go b/KC-DB/storage/postgres/basestation_repository_test.go index 707df1b..66d5c27 100644 --- a/KC-DB/storage/postgres/basestation_repository_test.go +++ b/KC-DB/storage/postgres/basestation_repository_test.go @@ -96,7 +96,12 @@ func TestBaseStationRepository_Create_RejectsCrossTenantDuplicate(t *testing.T) var pqErr *pq.Error if errors.As(err, &pqErr) { assert.Equal(t, "23505", string(pqErr.Code), "Should be PostgreSQL unique violation") - assert.Equal(t, "unique_bs_eui", pqErr.Constraint, "Should reference global EUI constraint from migration 000080") + // Two unique constraints guard bs_eui: the original column constraint + // (basestations_eui_key, renamed with the column in migration 000060) + // and unique_bs_eui from migration 000080; either proves global + // uniqueness enforcement + assert.Contains(t, []string{"unique_bs_eui", "basestations_eui_key"}, pqErr.Constraint, + "Should reference a global EUI uniqueness constraint") } // Alternative check: Error should mention "already exists" or "unique" @@ -327,6 +332,7 @@ func TestBaseStation_CreateWithLocation(t *testing.T) { TenantID: 100, Name: "TestCreateWithLoc-BS1", ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), Latitude: &lat, Longitude: &lon, Altitude: &alt, @@ -383,6 +389,7 @@ func TestBaseStation_UpdatePreservesUnmodifiedLocation(t *testing.T) { TenantID: 100, Name: "TestUpdatePreserve-BS1", ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), Latitude: &lat, Longitude: &lon, Altitude: &alt, diff --git a/KC-DB/storage/postgres/endpoint_repository.go b/KC-DB/storage/postgres/endpoint_repository.go index 67288b7..c68fb8e 100644 --- a/KC-DB/storage/postgres/endpoint_repository.go +++ b/KC-DB/storage/postgres/endpoint_repository.go @@ -1362,39 +1362,18 @@ func (r *EndPointRepository) UpdateWithEUI(ctx context.Context, tenantID int64, return nil, fmt.Errorf("update dl_rx_status_queries.ep_eui: %w", err) } - // BIGINT columns: messages.ep_eui (uses uint64 representation) - oldUint64 := euiToUint64(oldEui) - newUint64 := euiToUint64(newEui) - _, err = tx.ExecContext(ctx, "UPDATE messages SET ep_eui = $1 WHERE ep_eui = $2", newUint64, oldUint64) + // BYTEA columns: messages.ep_eui (8-byte big-endian per migration 000135) + _, err = tx.ExecContext(ctx, "UPDATE messages SET ep_eui = $1 WHERE ep_eui = $2", newEui, oldEui) if err != nil { return nil, fmt.Errorf("update messages.ep_eui: %w", err) } - // Schema-aware update: messages_archive.ep_eui can be BYTEA (migration 001) or BIGINT (if future migration aligns it) - var archiveEpColType string - err = tx.GetContext(ctx, &archiveEpColType, ` - SELECT data_type - FROM information_schema.columns - WHERE table_name = 'messages_archive' AND column_name = 'ep_eui' - `) - if err != nil { - return nil, fmt.Errorf("detect messages_archive.ep_eui column type: %w", err) - } - if archiveEpColType == "bytea" { - _, err = tx.ExecContext(ctx, "UPDATE messages_archive SET ep_eui = $1 WHERE ep_eui = $2", newEui, oldEui) - } else { - _, err = tx.ExecContext(ctx, "UPDATE messages_archive SET ep_eui = $1 WHERE ep_eui = $2", newUint64, oldUint64) - } + // BYTEA columns: messages_archive.ep_eui (rebuilt LIKE messages by migration 000139) + _, err = tx.ExecContext(ctx, "UPDATE messages_archive SET ep_eui = $1 WHERE ep_eui = $2", newEui, oldEui) if err != nil { return nil, fmt.Errorf("update messages_archive.ep_eui: %w", err) } - // BYTEA columns: mioty_messages.ep_eui (nullable) - _, err = tx.ExecContext(ctx, "UPDATE mioty_messages SET ep_eui = $1 WHERE ep_eui = $2", newEui, oldEui) - if err != nil { - return nil, fmt.Errorf("update mioty_messages.ep_eui: %w", err) - } - // BYTEA columns: mioty_message_deduplication.ep_eui _, err = tx.ExecContext(ctx, "UPDATE mioty_message_deduplication SET ep_eui = $1 WHERE ep_eui = $2", newEui, oldEui) if err != nil { diff --git a/KC-DB/storage/postgres/endpoint_repository_test.go b/KC-DB/storage/postgres/endpoint_repository_test.go index d88ca2c..6599573 100644 --- a/KC-DB/storage/postgres/endpoint_repository_test.go +++ b/KC-DB/storage/postgres/endpoint_repository_test.go @@ -218,6 +218,7 @@ func TestCreate_BasicFields(t *testing.T) { EUI: eui, Name: "TestCreate-EP1", TenantID: 500, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1148,6 +1149,7 @@ func TestEndpointRepository_Create_RejectsCrossTenantDuplicate(t *testing.T) { TenantID: 100, OwnerTenantID: 100, Name: "TestCrossTenant-T100-EP1", + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1164,6 +1166,7 @@ func TestEndpointRepository_Create_RejectsCrossTenantDuplicate(t *testing.T) { TenantID: 200, // Different tenant OwnerTenantID: 200, Name: "TestCrossTenant-T200-EP2", + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1179,7 +1182,11 @@ func TestEndpointRepository_Create_RejectsCrossTenantDuplicate(t *testing.T) { var pqErr *pq.Error if errors.As(err, &pqErr) { assert.Equal(t, "23505", string(pqErr.Code), "Should be PostgreSQL unique violation") - assert.Equal(t, "unique_ep_eui", pqErr.Constraint, "Should reference global EUI constraint from migration 000080") + // Any of these prove global uniqueness enforcement: endpoints_eui_key is the + // migration-001 column constraint (name survives the 000029 eui->ep_eui rename), + // unique_ep_eui comes from migration 000080, endpoints_ep_eui_key from a fresh schema + assert.Contains(t, []string{"unique_ep_eui", "endpoints_ep_eui_key", "endpoints_eui_key"}, pqErr.Constraint, + "Should reference a global EUI uniqueness constraint") } // Alternative check: Error should mention "already exists" or "duplicate" @@ -1656,6 +1663,20 @@ func TestStreamAllForPropagation_QueryTextValidation(t *testing.T) { // ============================================================================= // TestCreate_WithDeviceModelID verifies DeviceModelID is persisted during endpoint creation +// createTestDeviceModel inserts a manufacturer and device model for the tenant +// and returns the device model ID, satisfying endpoints_device_model_id_fkey. +func createTestDeviceModel(t *testing.T, db *sqlx.DB, tenantID int64, code string) uuid.UUID { + t.Helper() + var mfrID, modelID uuid.UUID + require.NoError(t, db.QueryRow( + `INSERT INTO manufacturers (tenant_id, name) VALUES ($1, $2) RETURNING id`, + tenantID, "TestMfr-"+code).Scan(&mfrID)) + require.NoError(t, db.QueryRow( + `INSERT INTO device_models (manufacturer_id, tenant_id, name, code) VALUES ($1, $2, $3, $4) RETURNING id`, + mfrID, tenantID, "TestModel-"+code, code).Scan(&modelID)) + return modelID +} + func TestCreate_WithDeviceModelID(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") @@ -1679,13 +1700,14 @@ func TestCreate_WithDeviceModelID(t *testing.T) { nwkKey := make([]byte, 16) appKey := make([]byte, 16) - deviceModelID := uuid.New() + deviceModelID := createTestDeviceModel(t, db, 700, "test-model-700") endpoint := &models.EndPoint{ EUI: eui, Name: "TestDeviceModelID-EP1", TenantID: 700, OwnerTenantID: 700, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1730,13 +1752,14 @@ func TestGetByID_ReturnsDeviceModelID(t *testing.T) { nwkKey := make([]byte, 16) appKey := make([]byte, 16) - deviceModelID := uuid.New() + deviceModelID := createTestDeviceModel(t, db, 701, "test-model-701") endpoint := &models.EndPoint{ EUI: eui, Name: "TestGetByID-DevModel-EP1", TenantID: 701, OwnerTenantID: 701, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1779,13 +1802,14 @@ func TestListByTenantPaginated_ReturnsDeviceModelID(t *testing.T) { nwkKey := make([]byte, 16) appKey := make([]byte, 16) - deviceModelID := uuid.New() + deviceModelID := createTestDeviceModel(t, db, 702, "test-model-702") endpoint := &models.EndPoint{ EUI: eui, Name: "TestList-DevModel-EP1", TenantID: 702, OwnerTenantID: 702, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1828,13 +1852,14 @@ func TestGetByEUI_ReturnsDeviceModelID(t *testing.T) { nwkKey := make([]byte, 16) appKey := make([]byte, 16) - deviceModelID := uuid.New() + deviceModelID := createTestDeviceModel(t, db, 703, "test-model-703") endpoint := &models.EndPoint{ EUI: eui, Name: "TestGetByEUI-DevModel-EP1", TenantID: 703, OwnerTenantID: 703, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1905,6 +1930,7 @@ func TestUpdate_NilKeys_NoCheckViolation(t *testing.T) { EUI: eui, Name: "TestNilKeys-EP1", TenantID: 800, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, Tags: make(map[string]string), @@ -1958,6 +1984,7 @@ func TestUpdateWithEUI_NilKeys_NoCheckViolation(t *testing.T) { EUI: oldEui, Name: "TestNilKeysEUI-EP1", TenantID: 801, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, Tags: make(map[string]string), @@ -2015,6 +2042,7 @@ func TestListByTenantPaginated_ReturnsMIOTYConfigFields(t *testing.T) { Name: "TestList-MIOTYFields-EP1", TenantID: 810, OwnerTenantID: 810, + EPClass: "A", NwkSnKey: make([]byte, 16), AppKey: make([]byte, 16), CryptoMode: 0, @@ -2082,6 +2110,7 @@ func TestListByTenantPaginated_HandlesNullTypeEUI(t *testing.T) { Name: "TestList-NullTypeEUI-EP1", TenantID: 811, OwnerTenantID: 811, + EPClass: "A", NwkSnKey: make([]byte, 16), CryptoMode: 0, Tags: make(map[string]string), @@ -2125,6 +2154,7 @@ func TestUpdate_PersistsTypeEUI(t *testing.T) { Name: "TestUpdate-TypeEUI-EP1", TenantID: 812, OwnerTenantID: 812, + EPClass: "A", NwkSnKey: make([]byte, 16), CryptoMode: 0, Tags: make(map[string]string), @@ -2133,12 +2163,15 @@ func TestUpdate_PersistsTypeEUI(t *testing.T) { err := repo.Create(ctx, endpoint) require.NoError(t, err) - // Set type_eui via Update + // Reload so the struct carries DB defaults (ep_status, repetition), then set type_eui + reloaded, err := repo.GetByEUI(ctx, 812, eui[:]) + require.NoError(t, err) + var typeEUI models.EUI copy(typeEUI[:], []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}) - endpoint.TypeEUI = &typeEUI + reloaded.TypeEUI = &typeEUI - err = repo.Update(ctx, endpoint) + err = repo.Update(ctx, reloaded) require.NoError(t, err) // Verify via GetByEUI @@ -2175,6 +2208,7 @@ func TestUpdate_ClearsTypeEUI(t *testing.T) { Name: "TestUpdate-ClearTypeEUI-EP1", TenantID: 813, OwnerTenantID: 813, + EPClass: "A", NwkSnKey: make([]byte, 16), CryptoMode: 0, Tags: make(map[string]string), @@ -2184,9 +2218,12 @@ func TestUpdate_ClearsTypeEUI(t *testing.T) { err := repo.Create(ctx, endpoint) require.NoError(t, err) - // Clear type_eui - endpoint.TypeEUI = nil - err = repo.Update(ctx, endpoint) + // Reload so the struct carries DB defaults (ep_status, repetition), then clear type_eui + reloaded, err := repo.GetByEUI(ctx, 813, eui[:]) + require.NoError(t, err) + + reloaded.TypeEUI = nil + err = repo.Update(ctx, reloaded) require.NoError(t, err) // Verify cleared @@ -2219,6 +2256,7 @@ func TestUpdateWithEUI_PersistsTypeEUI(t *testing.T) { Name: "TestUpdateWithEUI-TypeEUI-EP1", TenantID: 814, OwnerTenantID: 814, + EPClass: "A", NwkSnKey: make([]byte, 16), CryptoMode: 0, Tags: make(map[string]string), @@ -2274,6 +2312,7 @@ func TestUpdateWithEUI_ClearsTypeEUI(t *testing.T) { Name: "TestUpdateWithEUI-ClearTypeEUI-EP1", TenantID: 815, OwnerTenantID: 815, + EPClass: "A", NwkSnKey: make([]byte, 16), CryptoMode: 0, Tags: make(map[string]string), diff --git a/KC-DB/storage/postgres/endpoint_session_migration_test.go b/KC-DB/storage/postgres/endpoint_session_migration_test.go index eeeb46f..5de8397 100644 --- a/KC-DB/storage/postgres/endpoint_session_migration_test.go +++ b/KC-DB/storage/postgres/endpoint_session_migration_test.go @@ -2,7 +2,6 @@ package postgres import ( "context" - "database/sql" "encoding/base64" "fmt" "testing" @@ -10,6 +9,7 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/crypto" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,7 +36,7 @@ func TestGetActive_LazyMigration(t *testing.T) { // Create test endpoint and session with legacy base64-encoded key tenantID := int64(1001) endpointID := int64(2001) - sessionID := int64(3001) + sessionID := uuid.NewString() // Create a test session key (16 bytes for testing) cleartextKey := []byte("test-session-key") @@ -125,7 +125,7 @@ func TestGetActive_NoMigrationWhenAlreadyRaw(t *testing.T) { // Create test endpoint and session with RAW GCM key tenantID := int64(1002) endpointID := int64(2002) - sessionID := int64(3002) + sessionID := uuid.NewString() // Create a test session key cleartextKey := []byte("test-session-key") @@ -201,7 +201,7 @@ func TestGetActive_TenantIsolationInMigration(t *testing.T) { tenant1ID := int64(1003) tenant2ID := int64(1004) endpointID := int64(2003) - sessionID := int64(3003) + sessionID := uuid.NewString() // Create keys key1 := []byte("tenant1-sess-key") @@ -282,27 +282,27 @@ func TestGetActive_TenantIsolationInMigration(t *testing.T) { func setupTestDB(t *testing.T) *DB { t.Helper() - // Use test database connection - connStr := "host=localhost port=5433 user=kilocenter password=changeme dbname=kilocenter sslmode=disable" - conn, err := sql.Open("postgres", connStr) - if err != nil { - t.Fatalf("Failed to connect to test database: %v", err) - } - - // Test connection - if err := conn.Ping(); err != nil { - t.Fatalf("Failed to ping test database: %v", err) + sqlxDB, cleanup := SetupPostgresContainer(t) + t.Cleanup(cleanup) + + // Seed the tenant rows referenced by the lazy-migration tests + for id, name := range map[int64]string{ + 1001: "MigrationTenant1001", + 1002: "MigrationTenant1002", + 1003: "MigrationTenant1003", + 1004: "MigrationTenant1004", + } { + createTestTenant(t, sqlxDB, id, name) } logger.Initialize("error", "json") log := logger.Get() - db := &DB{ - conn: conn, - log: log, + return &DB{ + conn: sqlxDB.DB, + sqlxDB: sqlxDB, + log: log, } - - return db } func cleanupTestDB(t *testing.T, db *DB) { diff --git a/KC-DB/storage/postgres/eui64_integration_test.go b/KC-DB/storage/postgres/eui64_integration_test.go index b86cfe9..4b386a6 100644 --- a/KC-DB/storage/postgres/eui64_integration_test.go +++ b/KC-DB/storage/postgres/eui64_integration_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -70,6 +70,61 @@ func TestEUI64BaseStationPersistence(t *testing.T) { } } +// TestEUI64BaseStationListWithStats verifies the message-statistics join +// matches BYTEA bs_eui values across the full unsigned range (regression: +// the join previously cast basestations.bs_eui to signed BIGINT). +func TestEUI64BaseStationListWithStats(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + db, cleanup := SetupPostgresContainer(t) + defer cleanup() + + const tenantID = int64(403) + createTestTenant(t, db, tenantID, "TestTenantEUI64Stats") + + repo := NewBaseStationRepository(db) + ctx := testutil.TestContext() + + const bsEUI = uint64(0xCAFECAFECAFECAFE) + var euiArr models.EUI + copy(euiArr[:], eui64Bytes(bsEUI)) + + bs := &models.BaseStation{ + EUI: euiArr, + TenantID: tenantID, + Name: "TestEUI64-Stats-BS", + ConnectionType: models.ConnectionTypeBSSCI, + ServiceCenterURL: testServiceCenterURLPtr(), + } + require.NoError(t, repo.Create(ctx, bs)) + + received := time.Now() + insertArchivalMessage(t, db, archivalMessageParams{ + TenantID: tenantID, EpEUI: 0x8000000000000001, BsEUI: bsEUI, ReceivedAt: received, + }) + insertArchivalMessage(t, db, archivalMessageParams{ + TenantID: tenantID, EpEUI: 0xFFFFFFFFFFFFFFFF, BsEUI: bsEUI, ReceivedAt: received, + }) + + stats, total, err := repo.ListWithStats(ctx, tenantID, 10, 0) + require.NoError(t, err) + require.Equal(t, int64(1), total) + + var found *BaseStationWithStats + for _, s := range stats { + if string(s.BsEui) == string(eui64Bytes(bsEUI)) { + found = s + break + } + } + require.NotNil(t, found, "high-bit BS must appear in ListWithStats") + assert.Equal(t, 2, found.MessageCount, + "messages with high-bit bs_eui must join to the base station") + assert.Equal(t, 2, found.UniqueEndpoints) +} + // TestEUI64MessagePersistence verifies uplink messages carrying full-range // endpoint and base station EUIs survive the message store round-trip. func TestEUI64MessagePersistence(t *testing.T) { @@ -120,7 +175,7 @@ func TestEUI64EndpointPersistence(t *testing.T) { for _, eui := range eui64MatrixValues { var stored []byte require.NoError(t, db.QueryRow(` - INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, sh_addr, nwk_sn_key, bidi) + INSERT INTO endpoints (tenant_id, owner_tenant_id, ep_eui, name, sh_addr, nwk_key, bidi) VALUES ($1, $1, $2, $3, $4, $5, true) RETURNING ep_eui`, tenantID, eui64Bytes(eui), "TestEUI64-EP", 0x1234, diff --git a/KC-DB/storage/postgres/message_repository.go b/KC-DB/storage/postgres/message_repository.go index 6f8f112..ffed2b5 100644 --- a/KC-DB/storage/postgres/message_repository.go +++ b/KC-DB/storage/postgres/message_repository.go @@ -3,6 +3,7 @@ package postgres import ( "context" "database/sql" + "encoding/base64" "encoding/binary" "encoding/json" "fmt" @@ -295,7 +296,7 @@ func (r *MessageRepository) CreateAttachMessage(ctx context.Context, msg *mioty. subpacketsStr := string(subpacketsBytes) // Insert into messages table (canonical table per migration 047) - // Column mapping: ep_eui/bs_eui as BIGINT, command_type, op_id, subpackets JSONB + // Column mapping: ep_eui/bs_eui as 8-byte BYTEA, command_type, op_id, subpackets JSONB // Note: AttachMessage struct doesn't have OrgUUID - use nil query := ` INSERT INTO messages ( @@ -379,7 +380,7 @@ func (r *MessageRepository) CreateDetachMessage(ctx context.Context, msg *mioty. subpacketsStr := string(subpacketsBytes) // Insert into messages table (canonical table per migration 047) - // Column mapping: ep_eui/bs_eui as BIGINT, command_type, op_id, subpackets JSONB + // Column mapping: ep_eui/bs_eui as 8-byte BYTEA, command_type, op_id, subpackets JSONB query := ` INSERT INTO messages ( id, tenant_id, org_uuid, command_type, op_id, ep_eui, bs_eui, @@ -448,7 +449,7 @@ func (r *MessageRepository) CreateAttachPropagateMessage(ctx context.Context, ms subpacketsStr := string(subpacketsBytes) // Insert into messages table (canonical table per migration 047) - // Uses messages schema: UUID id, BIGINT ep_eui/bs_eui, subpackets JSONB for propagate fields + // Uses messages schema: UUID id, 8-byte BYTEA ep_eui/bs_eui, subpackets JSONB for propagate fields query := ` INSERT INTO messages ( id, tenant_id, org_uuid, command_type, op_id, ep_eui, bs_eui, @@ -518,7 +519,7 @@ func (r *MessageRepository) CreateDetachPropagateMessage(ctx context.Context, ms subpacketsStr := string(subpacketsBytes) // Insert into messages table (canonical table per migration 047) - // Column mapping: ep_eui/bs_eui as BIGINT, command_type, op_id, subpackets JSONB + // Column mapping: ep_eui/bs_eui as 8-byte BYTEA, command_type, op_id, subpackets JSONB query := ` INSERT INTO messages ( id, tenant_id, org_uuid, command_type, op_id, ep_eui, bs_eui, @@ -678,7 +679,7 @@ func (r *MessageRepository) GetDetachMessage(ctx context.Context, id string, ten var epEuiB, bsEuiB []byte // Query from canonical messages table (migration 047) - // Column mapping: op_id (not operation_id), bs_eui (not basestation_eui), ep_eui as BIGINT + // Column mapping: op_id (not operation_id), bs_eui (not basestation_eui), ep_eui as 8-byte BYTEA query := ` SELECT id, tenant_id, org_uuid, command_type, op_id, ep_eui, bs_eui, @@ -716,8 +717,14 @@ func (r *MessageRepository) GetDetachMessage(ctx context.Context, id string, ten if err := json.Unmarshal(subpacketsJSON, &subpacketsMap); err != nil { return nil, fmt.Errorf("deserialize subpackets: %w", err) } - // Extract signature from subpackets (stored as []interface{} from JSON) - if sig, ok := subpacketsMap["signature"].([]interface{}); ok { + // Extract signature from subpackets: json.Marshal encodes []byte as a + // base64 string; legacy rows may carry a numeric array + switch sig := subpacketsMap["signature"].(type) { + case string: + if decoded, decErr := base64.StdEncoding.DecodeString(sig); decErr == nil { + msg.Signature = decoded + } + case []interface{}: msg.Signature = make([]byte, len(sig)) for i, v := range sig { if f, ok := v.(float64); ok { diff --git a/KC-DB/storage/postgres/message_repository_test.go b/KC-DB/storage/postgres/message_repository_test.go index 50fa3a0..d48efdb 100644 --- a/KC-DB/storage/postgres/message_repository_test.go +++ b/KC-DB/storage/postgres/message_repository_test.go @@ -92,7 +92,7 @@ func TestGetDetachMessage_Success(t *testing.T) { assert.Equal(t, orgUUIDStr, *retrieved.OrgUUID, "org_uuid must match persisted value") // Cleanup - CleanupTestData(t, db, "mioty_messages", "id", messageID) + CleanupTestData(t, db, "messages", "id", messageID) } // TestGetDetachMessage_NotFound verifies error handling for non-existent message ID. @@ -188,7 +188,7 @@ func TestGetDetachMessage_TenantIsolation(t *testing.T) { assert.Nil(t, retrieved2, "Message must not be returned for wrong tenant") // Cleanup - CleanupTestData(t, db, "mioty_messages", "id", messageID) + CleanupTestData(t, db, "messages", "id", messageID) } // TestGetDetachMessage_WithOptionalFields verifies optional fields are @@ -290,7 +290,7 @@ func TestGetDetachMessage_WithOptionalFields(t *testing.T) { assert.Len(t, retrieved.Subpackets.RSSI, 2, "subpackets RSSI must have 2 entries") // Cleanup - CleanupTestData(t, db, "mioty_messages", "id", messageID) + CleanupTestData(t, db, "messages", "id", messageID) } // Helper function to convert uint64 EUI to byte array diff --git a/KC-DB/storage/postgres/operation_status_repository_test.go b/KC-DB/storage/postgres/operation_status_repository_test.go index 3a2f109..1ec5721 100644 --- a/KC-DB/storage/postgres/operation_status_repository_test.go +++ b/KC-DB/storage/postgres/operation_status_repository_test.go @@ -98,9 +98,9 @@ func TestGetOperationEventsByID_AttachFilter(t *testing.T) { _, err := db.Exec(` INSERT INTO system_events (event_category, event_type, title, occurred_at, data) VALUES - ($1, 'attach_propagate', 'Attach started', NOW() - interval '1 hour', $2), + ($1, 'attach_propagate_initiated', 'Attach started', NOW() - interval '1 hour', $2), ($1, 'endpoint_attached', 'Endpoint attached', NOW() - interval '30 minutes', $2), - ($1, 'detach_propagate', 'Detach started', NOW() - interval '15 minutes', $2) + ($1, 'detach_propagate_initiated', 'Detach started', NOW() - interval '15 minutes', $2) `, testEventCategoryBSSCI, `{"operation_id": "`+testOpID+`"}`) require.NoError(t, err) @@ -147,8 +147,8 @@ func TestGetOperationEventsByID_DetachFilter(t *testing.T) { _, err := db.Exec(` INSERT INTO system_events (event_category, event_type, title, occurred_at, data) VALUES - ($1, 'attach_propagate', 'Attach started', NOW() - interval '1 hour', $2), - ($1, 'detach_propagate', 'Detach started', NOW() - interval '30 minutes', $2), + ($1, 'attach_propagate_initiated', 'Attach started', NOW() - interval '1 hour', $2), + ($1, 'detach_propagate_initiated', 'Detach started', NOW() - interval '30 minutes', $2), ($1, 'endpoint_detached', 'Endpoint detached', NOW() - interval '15 minutes', $2) `, testEventCategoryBSSCI, `{"operation_id": "`+testOpID+`"}`) require.NoError(t, err) @@ -197,8 +197,8 @@ func TestGetOperationEventsByID_AllOperations(t *testing.T) { _, err := db.Exec(` INSERT INTO system_events (event_category, event_type, title, occurred_at, data) VALUES - ($1, 'attach_propagate', 'Attach started', NOW() - interval '1 hour', $2), - ($1, 'detach_propagate', 'Detach started', NOW() - interval '30 minutes', $2), + ($1, 'attach_propagate_initiated', 'Attach started', NOW() - interval '1 hour', $2), + ($1, 'detach_propagate_initiated', 'Detach started', NOW() - interval '30 minutes', $2), ($1, 'endpoint_attached', 'Endpoint attached', NOW() - interval '20 minutes', $2), ($1, 'endpoint_detached', 'Endpoint detached', NOW() - interval '10 minutes', $2) `, testEventCategoryBSSCI, `{"operation_id": "`+testOpID+`"}`) diff --git a/KC-DB/storage/postgres/schema_audit_test.go b/KC-DB/storage/postgres/schema_audit_test.go index 5454675..4ce2eb1 100644 --- a/KC-DB/storage/postgres/schema_audit_test.go +++ b/KC-DB/storage/postgres/schema_audit_test.go @@ -21,7 +21,14 @@ func TestSchemaBaseline(t *testing.T) { defer cleanup() timestamp := time.Now().Format("20060102-150405") - outputFile := fmt.Sprintf("../../../compliance/evidence/schema-audit-%s.md", timestamp) + // Evidence is written outside the public module tree (repo hygiene: + // kilocenter-modules must not carry internal compliance artefacts); + // override with SCHEMA_AUDIT_EVIDENCE_DIR when the internal layout differs + evidenceDir := os.Getenv("SCHEMA_AUDIT_EVIDENCE_DIR") + if evidenceDir == "" { + evidenceDir = "../../../../compliance/evidence/automation" + } + outputFile := fmt.Sprintf("%s/schema-audit-%s.md", evidenceDir, timestamp) var output strings.Builder output.WriteString("# Database Schema Audit\n\n") diff --git a/KC-DB/storage/postgres/test_helpers_test.go b/KC-DB/storage/postgres/test_helpers_test.go index bd38a85..cb842f3 100644 --- a/KC-DB/storage/postgres/test_helpers_test.go +++ b/KC-DB/storage/postgres/test_helpers_test.go @@ -134,8 +134,6 @@ type EndpointInsertParams struct { // Extended fields for specialized tests ShAddr uint32 // Short address Bidi bool // Bidirectional flag - NwkSnKey []byte // Network session key (if nil, defaults to empty) - AppSnKey []byte // Application session key (if nil, defaults to empty) Sign []byte // Signature key (detach tests) Preshared []byte // Preshared key (detach tests) } @@ -158,12 +156,6 @@ func applyEndpointDefaults(p *EndpointInsertParams) { if p.AppKey == nil { p.AppKey = make([]byte, 16) } - if p.NwkSnKey == nil { - p.NwkSnKey = []byte{} - } - if p.AppSnKey == nil { - p.AppSnKey = []byte{} - } } // insertEndpoint inserts a test endpoint using *sqlx.DB (for testcontainer-based tests). @@ -245,11 +237,11 @@ func insertEndpointWithConn(ctx context.Context, t *testing.T, conn *sql.DB, p E _, err := conn.ExecContext(ctx, ` INSERT INTO endpoints ( id, ep_eui, name, description, tenant_id, owner_tenant_id, - sh_addr, bidi, nwk_sn_key, app_sn_key, + sh_addr, bidi, nwk_key, app_key, crypto_mode, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) `, p.ID, euiToBytes(p.EpEUI), p.Name, p.Description, p.TenantID, p.OwnerTenantID, - p.ShAddr, p.Bidi, p.NwkSnKey, p.AppSnKey, + p.ShAddr, p.Bidi, p.NwkKey, p.AppKey, p.CryptoMode, now, now) require.NoError(t, err, "Failed to insert test endpoint with explicit ID") return p.ID @@ -260,12 +252,12 @@ func insertEndpointWithConn(ctx context.Context, t *testing.T, conn *sql.DB, p E err := conn.QueryRowContext(ctx, ` INSERT INTO endpoints ( ep_eui, name, description, tenant_id, owner_tenant_id, - sh_addr, bidi, nwk_sn_key, app_sn_key, + sh_addr, bidi, nwk_key, app_key, crypto_mode, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id `, euiToBytes(p.EpEUI), p.Name, p.Description, p.TenantID, p.OwnerTenantID, - p.ShAddr, p.Bidi, p.NwkSnKey, p.AppSnKey, + p.ShAddr, p.Bidi, p.NwkKey, p.AppKey, p.CryptoMode, now, now).Scan(&id) require.NoError(t, err, "Failed to insert test endpoint") diff --git a/KC-DB/storage/postgres/transaction_downlink.go b/KC-DB/storage/postgres/transaction_downlink.go index 2753251..c650a3f 100644 --- a/KC-DB/storage/postgres/transaction_downlink.go +++ b/KC-DB/storage/postgres/transaction_downlink.go @@ -142,7 +142,7 @@ func (r *transactionalMIOTYDownlinkRepository) ReserveNextPendingDownlink( // MarkReservedAsQueued transitions reserved → queued with transmission metadata // Aligns with UpdateDownlinkResult pattern from postgres.go -// packetCnt is nullable - pass nil if unknown (dlDataQueCmp will update later) +// packetCnt is nullable - pass nil if unknown (set from the dlDataRes transmission result, BSSCI 5.14) // orgID filters by organization; nil = no org filter (backward compatible) func (r *transactionalMIOTYDownlinkRepository) MarkReservedAsQueued( ctx context.Context, @@ -180,7 +180,7 @@ func (r *transactionalMIOTYDownlinkRepository) MarkReservedAsQueued( args = append(args, *orgID) } - // NOTE: transmission_result stays NULL here (not 'sent') because result unknown until dlDataQueCmp + // NOTE: transmission_result stays NULL here (not 'sent') because the result arrives via dlDataRes (BSSCI 5.14) // Status 'queued' indicates "sent to BS, awaiting actual transmission" // nolint:gosec // G201: orgFilter is a static string (empty or " AND (...)" with parameterized value), not user input query := fmt.Sprintf(` diff --git a/KC-Web/eslint.config.js b/KC-Web/eslint.config.js index 1d0a147..00796b1 100644 --- a/KC-Web/eslint.config.js +++ b/KC-Web/eslint.config.js @@ -151,9 +151,10 @@ export default tseslint.config( '@components/layout/**', // Config files '@config/**', - // Local relative imports within modules (pages, components subdirs) + // Local relative imports within modules (pages, components, utils subdirs) '**/pages/**', '**/components/**', + '**/utils/**', // gRPC-web generated stubs and google-protobuf '@services/grpc/**', 'google-protobuf/**', diff --git a/KC-Web/src/hooks/useRealtime.ts b/KC-Web/src/hooks/useRealtime.ts index 2936883..055793b 100644 --- a/KC-Web/src/hooks/useRealtime.ts +++ b/KC-Web/src/hooks/useRealtime.ts @@ -383,8 +383,11 @@ function useCatchUpOnStreamReconnect(): void { }); useEffect(() => { + // Snapshot the ref once: the flight state object is stable for the effect's + // lifetime, and the cleanup must not re-read the ref (react-hooks/exhaustive-deps). + const flight = flightRef.current; + const scheduleInvalidate = () => { - const flight = flightRef.current; if (flight.pendingInvalidate) { clearTimeout(flight.pendingInvalidate); } @@ -404,7 +407,6 @@ function useCatchUpOnStreamReconnect(): void { const unsubscribe = realtimeService.onConnectionEvent((evt) => { if (evt.streamKind !== REALTIME_STREAM_KIND.EVENT) return; - const flight = flightRef.current; if (evt.type === "realtime_connected") { const isReconnect = flight.hasConnected && !flight.connected; @@ -423,7 +425,6 @@ function useCatchUpOnStreamReconnect(): void { return () => { unsubscribe(); - const flight = flightRef.current; if (flight.pendingInvalidate) { clearTimeout(flight.pendingInvalidate); flight.pendingInvalidate = null; diff --git a/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx b/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx index 0ce1110..1fc0942 100644 --- a/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx +++ b/KC-Web/src/modules/endpoints/components/EditEndPointDialog.tsx @@ -25,8 +25,9 @@ import { } from "@utils/formatters"; import { MIOTY_KEY_BYTE_LENGTH } from "@constants/app"; import { ENDPOINT_FORM } from "@constants/messages"; -import { SecurityKeyFields } from "./EndpointFormFields"; + import DeviceModelSelector from "./DeviceModelSelector"; +import { SecurityKeyFields } from "./EndpointFormFields"; import EndpointSettingsSection from "./EndpointSettingsSection"; interface EditEndPointDialogProps { diff --git a/KC-Web/src/modules/organizations/pages/Organizations.tsx b/KC-Web/src/modules/organizations/pages/Organizations.tsx index 1fb3f50..d33809e 100644 --- a/KC-Web/src/modules/organizations/pages/Organizations.tsx +++ b/KC-Web/src/modules/organizations/pages/Organizations.tsx @@ -19,6 +19,7 @@ import { Typography, } from "@mui/material"; +import SearchField from "@components/common/SearchField"; import { useSession } from "@contexts/SessionContext"; import { useOrganizations } from "@hooks/useOrganizations"; import { ORG_STATE, ROUTES } from "@constants/app"; @@ -34,8 +35,6 @@ import { SuccessIcon, } from "@theme/icons"; -import SearchField from "@components/common/SearchField"; - import AddOrganizationDialog from "../components/AddOrganizationDialog"; import OrganizationsTable from "../components/OrganizationsTable"; diff --git a/KC-Web/src/modules/users/pages/OrganizationUsers.tsx b/KC-Web/src/modules/users/pages/OrganizationUsers.tsx index e6b2c09..d088462 100644 --- a/KC-Web/src/modules/users/pages/OrganizationUsers.tsx +++ b/KC-Web/src/modules/users/pages/OrganizationUsers.tsx @@ -29,6 +29,7 @@ import { Typography, } from "@mui/material"; +import SearchField from "@components/common/SearchField"; import { useOrganization as useOrganizationContext } from "@contexts/OrganizationContext"; import { useSession } from "@contexts/SessionContext"; import { useCapabilities } from "@hooks/useCapabilities"; @@ -45,8 +46,6 @@ import { } from "@constants/messages"; import { AddIcon, ArrowBackIcon, PeopleIcon } from "@theme/icons"; -import SearchField from "@components/common/SearchField"; - import AddUserDialog from "../components/AddUserDialog"; import OrganizationUserDialog from "../components/OrganizationUserDialog"; import OrganizationUsersTable from "../components/OrganizationUsersTable"; diff --git a/KC-Web/src/modules/users/pages/Users.tsx b/KC-Web/src/modules/users/pages/Users.tsx index 6128a65..6024ea4 100644 --- a/KC-Web/src/modules/users/pages/Users.tsx +++ b/KC-Web/src/modules/users/pages/Users.tsx @@ -25,6 +25,7 @@ import { Typography, } from "@mui/material"; +import SearchField from "@components/common/SearchField"; import { useSession } from "@contexts/SessionContext"; import { ORG_MEMBER_STATUS, ORG_ROLE, ROUTES } from "@constants/app"; import { ERR_LOAD_USERS, USERS_PAGE } from "@constants/messages"; @@ -36,8 +37,6 @@ import { SuccessIcon, } from "@theme/icons"; -import SearchField from "@components/common/SearchField"; - import AddUserDialog from "../components/AddUserDialog"; import UsersTableBase, { type OrderBy } from "../components/UsersTableBase"; diff --git a/KC-Web/src/services/grpc/identity_pb.d.ts b/KC-Web/src/services/grpc/identity_pb.d.ts index 1fd4da5..eebf065 100644 --- a/KC-Web/src/services/grpc/identity_pb.d.ts +++ b/KC-Web/src/services/grpc/identity_pb.d.ts @@ -1708,6 +1708,9 @@ export class CreateApiKeyRequest extends jspb.Message { getExpiresAt(): google_protobuf_timestamp_pb.Timestamp | undefined; setExpiresAt(value?: google_protobuf_timestamp_pb.Timestamp): void; + getOrganizationId(): string; + setOrganizationId(value: string): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): CreateApiKeyRequest.AsObject; static toObject(includeInstance: boolean, msg: CreateApiKeyRequest): CreateApiKeyRequest.AsObject; @@ -1723,6 +1726,7 @@ export namespace CreateApiKeyRequest { name: string, keyType: string, expiresAt?: google_protobuf_timestamp_pb.Timestamp.AsObject, + organizationId: string, } } diff --git a/KC-Web/src/services/grpc/identity_pb.js b/KC-Web/src/services/grpc/identity_pb.js index 680b503..7b0e89e 100644 --- a/KC-Web/src/services/grpc/identity_pb.js +++ b/KC-Web/src/services/grpc/identity_pb.js @@ -13306,7 +13306,8 @@ proto.kilocenter.api.v1.CreateApiKeyRequest.toObject = function(includeInstance, var f, obj = { name: jspb.Message.getFieldWithDefault(msg, 1, ""), keyType: jspb.Message.getFieldWithDefault(msg, 2, ""), - expiresAt: (f = msg.getExpiresAt()) && google_protobuf_timestamp_pb.Timestamp.toObject(includeInstance, f) + expiresAt: (f = msg.getExpiresAt()) && google_protobuf_timestamp_pb.Timestamp.toObject(includeInstance, f), + organizationId: jspb.Message.getFieldWithDefault(msg, 4, "") }; if (includeInstance) { @@ -13356,6 +13357,10 @@ proto.kilocenter.api.v1.CreateApiKeyRequest.deserializeBinaryFromReader = functi reader.readMessage(value,google_protobuf_timestamp_pb.Timestamp.deserializeBinaryFromReader); msg.setExpiresAt(value); break; + case 4: + var value = /** @type {string} */ (reader.readString()); + msg.setOrganizationId(value); + break; default: reader.skipField(); break; @@ -13407,6 +13412,13 @@ proto.kilocenter.api.v1.CreateApiKeyRequest.serializeBinaryToWriter = function(m google_protobuf_timestamp_pb.Timestamp.serializeBinaryToWriter ); } + f = message.getOrganizationId(); + if (f.length > 0) { + writer.writeString( + 4, + f + ); + } }; @@ -13483,6 +13495,24 @@ proto.kilocenter.api.v1.CreateApiKeyRequest.prototype.hasExpiresAt = function() }; +/** + * optional string organization_id = 4; + * @return {string} + */ +proto.kilocenter.api.v1.CreateApiKeyRequest.prototype.getOrganizationId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); +}; + + +/** + * @param {string} value + * @return {!proto.kilocenter.api.v1.CreateApiKeyRequest} returns this + */ +proto.kilocenter.api.v1.CreateApiKeyRequest.prototype.setOrganizationId = function(value) { + return jspb.Message.setProto3StringField(this, 4, value); +}; + + From 7682b731daddc221e303a3262124cb4c606f892a Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:56 +0200 Subject: [PATCH 03/25] fix(bssci): safe session resume and correct operation lifecycle Session resume (CHIRP-3541): Session is split into a transport-free ProtocolSessionState, resume becomes spec-conformant and tenant-scoped, and the connect state machine is completed so activation is atomic - a half-activated session can no longer be observed. Operation lifecycle (CHIRP-3542): the command-direction map is corrected and its generator made deterministic, service-center-initiated commands arriving inbound are rejected, pending operations are persisted before they are cached and before the frame is sent, an inbound error is answered only with errorAck instead of a guessed completion, and SC-initiated operations are finalized after completion instead of leaking. Migration 000140 purges pending operations orphaned by session ownership. --- KC-Core/cmd/kilocenter/builders/protocol.go | 1 + KC-Core/config/config.yaml | 1 + KC-Core/internal/grpc/core_service_test.go | 50 ++- .../services/bssci/audit_logger_test.go | 16 +- .../bssci/downlink_dispatcher_test.go | 48 ++- .../bssci/propagation_service_test.go | 5 +- .../services/bssci/session_resume_db_test.go | 239 ++++++++---- .../services/bssci/session_service.go | 158 +++++--- .../internal/services/bssci/status_service.go | 38 +- .../bssci/status_service_integration_test.go | 30 +- .../internal/services/bssci/test_helpers.go | 5 + .../pkg/bssci/attach_detach_profile_test.go | 36 +- .../attach_propagate_integration_test.go | 46 ++- .../bssci/attach_replay_protection_test.go | 72 ++-- KC-Core/pkg/bssci/connect_handler_test.go | 71 ++-- KC-Core/pkg/bssci/constants.go | 3 + KC-Core/pkg/bssci/contracts.go | 45 ++- KC-Core/pkg/bssci/detach_integration_test.go | 17 +- .../bssci/detach_propagate_complete_test.go | 63 +-- KC-Core/pkg/bssci/dl_rx_status_test.go | 52 +-- KC-Core/pkg/bssci/downlink_handlers.go | 168 ++------ .../bssci/downlink_handlers_actual_test.go | 8 +- .../downlink_handlers_integration_test.go | 8 +- KC-Core/pkg/bssci/downlink_handlers_test.go | 16 +- KC-Core/pkg/bssci/encoding_test.go | 8 +- KC-Core/pkg/bssci/epstatus_forward_test.go | 72 ++-- KC-Core/pkg/bssci/errors_catalog.go | 59 ++- KC-Core/pkg/bssci/log_messages.go | 16 +- .../bssci/mandatory_field_validation_test.go | 145 ++++--- KC-Core/pkg/bssci/message_assembly_test.go | 42 +- KC-Core/pkg/bssci/message_metadata.go | 23 +- .../bssci/message_metadata_directions_gen.go | 21 +- .../bssci/message_normalization_gate_test.go | 29 +- KC-Core/pkg/bssci/mqtt_publish_test.go | 128 +++--- .../pkg/bssci/operation_id_validation_test.go | 25 +- KC-Core/pkg/bssci/outbound_validation_test.go | 48 ++- KC-Core/pkg/bssci/pending_op_leak_test.go | 96 +++++ .../pkg/bssci/pending_ops_collision_test.go | 45 ++- KC-Core/pkg/bssci/phase_c2_regression_test.go | 46 ++- KC-Core/pkg/bssci/protocol_edge_cases_test.go | 210 +++++----- KC-Core/pkg/bssci/server.go | 364 ++++++++++-------- KC-Core/pkg/bssci/session_lookup_test.go | 246 +++++++----- .../pkg/bssci/session_opid_rollback_test.go | 36 +- KC-Core/pkg/bssci/session_resume_opid_test.go | 30 +- .../bssci/status_attach_remediation_test.go | 66 ++-- KC-Core/pkg/bssci/status_handlers.go | 37 +- KC-Core/pkg/bssci/status_handlers_test.go | 363 ++++++++--------- KC-Core/pkg/bssci/sublayer_test.go | 12 +- KC-Core/pkg/bssci/tenant_context_test.go | 4 +- KC-Core/pkg/bssci/tenant_isolation_test.go | 50 ++- KC-Core/pkg/bssci/test_helpers_test.go | 22 +- .../uldata_deduplication_integration_test.go | 16 +- KC-Core/pkg/bssci/version_interop_test.go | 97 +++++ .../pkg/bssci/vm_handlers_integration_test.go | 12 +- KC-Core/pkg/bssci/vm_handlers_test.go | 42 +- KC-Core/pkg/bssci/vm_prefix_guard_test.go | 8 +- KC-Core/pkg/config/constants.go | 3 + KC-Core/pkg/config/loader.go | 1 + KC-Core/pkg/config/protocol.go | 9 +- KC-Core/pkg/config/service_center.go | 4 + KC-Core/pkg/config/service_center_test.go | 32 +- KC-Core/pkg/management/bssci_manager_test.go | 16 +- ...ompleted_bssci_pending_operations.down.sql | 7 + ..._completed_bssci_pending_operations.up.sql | 52 +++ KC-DB/storage/mioty/types.go | 20 +- 65 files changed, 2201 insertions(+), 1557 deletions(-) create mode 100644 KC-Core/pkg/bssci/pending_op_leak_test.go create mode 100644 KC-DB/migrations/000140_purge_completed_bssci_pending_operations.down.sql create mode 100644 KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql diff --git a/KC-Core/cmd/kilocenter/builders/protocol.go b/KC-Core/cmd/kilocenter/builders/protocol.go index 9010f15..40e6560 100644 --- a/KC-Core/cmd/kilocenter/builders/protocol.go +++ b/KC-Core/cmd/kilocenter/builders/protocol.go @@ -81,6 +81,7 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol MessageEncoding: cfg.Protocol.MessageEncoding, DetachSignatureValidationEnabled: cfg.Protocol.DetachSignatureValidationEnabled, OperationAckTimeout: time.Duration(cfg.Protocol.AckTimeout) * time.Millisecond, + ConnectionEstablishmentTimeout: time.Duration(cfg.Protocol.ConnectionEstablishmentTimeout) * time.Millisecond, DuplicateWindow: time.Duration(cfg.Protocol.DuplicateWindow) * time.Second, CertificatePollInterval: cfg.Protocol.BSCICertificatePollInterval, } diff --git a/KC-Core/config/config.yaml b/KC-Core/config/config.yaml index 1d77b42..2b8b2b4 100644 --- a/KC-Core/config/config.yaml +++ b/KC-Core/config/config.yaml @@ -31,6 +31,7 @@ protocol: # Protocol parameters max_retransmissions: 3 ack_timeout: 5000 + connection_establishment_timeout: 30000 duplicate_window: 300 message_encoding: "msgpack" diff --git a/KC-Core/internal/grpc/core_service_test.go b/KC-Core/internal/grpc/core_service_test.go index b5e782a..41e0574 100644 --- a/KC-Core/internal/grpc/core_service_test.go +++ b/KC-Core/internal/grpc/core_service_test.go @@ -559,15 +559,17 @@ func TestQueryDLRXStatus_Success(t *testing.T) { testSessions: make(map[string]*bssci.Session), } mockDir.AddTestSession(&bssci.Session{ - ID: "session-ready", - BaseStationEUI: 0x1122334455667788, - Bidirectional: true, - HandshakeComplete: true, - ResolvedTenantID: 42, - Connected: time.Now(), - LastSeen: time.Now(), - ClientVersion: "1.0.0", - NegotiatedVersion: "1.0.0", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "session-ready", + BaseStationEUI: 0x1122334455667788, + HandshakeComplete: true, + ResolvedTenantID: 42, + ClientVersion: "1.0.0", + NegotiatedVersion: "1.0.0", + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }) mockCmd := &mockDownlinkCmd{ @@ -691,12 +693,15 @@ func TestQueryDLRXStatus_SessionNotReady(t *testing.T) { testSessions: make(map[string]*bssci.Session), } mockDir.AddTestSession(&bssci.Session{ - ID: "incomplete-handshake", - BaseStationEUI: 0x1122334455667788, - Bidirectional: true, - HandshakeComplete: false, // Handshake NOT complete - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "incomplete-handshake", + BaseStationEUI: 0x1122334455667788, + HandshakeComplete: false, + }, + Bidirectional: true, + // Handshake NOT complete + Connected: time.Now(), + LastSeen: time.Now(), }) svc := &CoreService{ @@ -731,12 +736,15 @@ func TestQueryDLRXStatus_SessionNotBidirectional(t *testing.T) { testSessions: make(map[string]*bssci.Session), } mockDir.AddTestSession(&bssci.Session{ - ID: "unidirectional", - BaseStationEUI: 0x1122334455667788, - Bidirectional: false, // NOT bidirectional - HandshakeComplete: true, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "unidirectional", + BaseStationEUI: 0x1122334455667788, + HandshakeComplete: true, + }, + // NOT bidirectional + Bidirectional: false, + Connected: time.Now(), + LastSeen: time.Now(), }) svc := &CoreService{ diff --git a/KC-Core/internal/services/bssci/audit_logger_test.go b/KC-Core/internal/services/bssci/audit_logger_test.go index 841a597..66526b1 100644 --- a/KC-Core/internal/services/bssci/audit_logger_test.go +++ b/KC-Core/internal/services/bssci/audit_logger_test.go @@ -52,8 +52,10 @@ func TestRecordQueueAck(t *testing.T) { logger := NewAuditLogger(store) session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: 0x123456789ABCDEF0, + }, UserProvidedName: "test-basestation", - BaseStationEUI: 0x123456789ABCDEF0, } tenant := "42" @@ -108,8 +110,10 @@ func TestRecordDLResultSent(t *testing.T) { logger := NewAuditLogger(store) session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: 0x1111222233334444, + }, UserProvidedName: "bs-success", - BaseStationEUI: 0x1111222233334444, } tenant := "100" @@ -153,8 +157,10 @@ func TestRecordDLResultExpired(t *testing.T) { logger := NewAuditLogger(store) session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: 0x5555666677778888, + }, UserProvidedName: "bs-expired", - BaseStationEUI: 0x5555666677778888, } tenant := "200" @@ -198,8 +204,10 @@ func TestRecordDLResultInvalid(t *testing.T) { logger := NewAuditLogger(store) session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: 0x9999AAAABBBBCCCC, + }, UserProvidedName: "bs-invalid", - BaseStationEUI: 0x9999AAAABBBBCCCC, } tenant := "300" diff --git a/KC-Core/internal/services/bssci/downlink_dispatcher_test.go b/KC-Core/internal/services/bssci/downlink_dispatcher_test.go index 9798d03..bc943e1 100644 --- a/KC-Core/internal/services/bssci/downlink_dispatcher_test.go +++ b/KC-Core/internal/services/bssci/downlink_dispatcher_test.go @@ -237,8 +237,10 @@ func TestDispatchIfAvailable_Success(t *testing.T) { ) session := &bssci.Session{ - ID: "test-session-123", - BaseStationEUI: 0x1234567890ABCDEF, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session-123", + BaseStationEUI: 0x1234567890ABCDEF, + }, } dispatched, err := dispatcher.DispatchIfAvailable( @@ -284,7 +286,11 @@ func TestDispatchIfAvailable_NoPendingDownlinks(t *testing.T) { sendFn.Send, ) - session := &bssci.Session{ID: "test-session"} + session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + }, + } dispatched, err := dispatcher.DispatchIfAvailable( context.Background(), @@ -317,7 +323,11 @@ func TestDispatchIfAvailable_NoTenantContext(t *testing.T) { sendFn.Send, ) - session := &bssci.Session{ID: "test-session"} + session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + }, + } dispatched, err := dispatcher.DispatchIfAvailable( context.Background(), @@ -352,7 +362,11 @@ func TestDispatchIfAvailable_TransactionBeginError(t *testing.T) { sendFn.Send, ) - session := &bssci.Session{ID: "test-session"} + session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + }, + } dispatched, err := dispatcher.DispatchIfAvailable( context.Background(), @@ -395,7 +409,11 @@ func TestDispatchIfAvailable_SendFunctionError(t *testing.T) { sendFn.Send, ) - session := &bssci.Session{ID: "test-session"} + session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + }, + } dispatched, err := dispatcher.DispatchIfAvailable( context.Background(), @@ -441,7 +459,11 @@ func TestDispatchIfAvailable_MarkQueuedError(t *testing.T) { sendFn.Send, ) - session := &bssci.Session{ID: "test-session"} + session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + }, + } dispatched, err := dispatcher.DispatchIfAvailable( context.Background(), @@ -487,7 +509,11 @@ func TestDispatchIfAvailable_CommitError(t *testing.T) { sendFn.Send, ) - session := &bssci.Session{ID: "test-session"} + session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + }, + } dispatched, err := dispatcher.DispatchIfAvailable( context.Background(), @@ -535,7 +561,11 @@ func TestDispatchIfAvailable_UsesUserDataIfPresent(t *testing.T) { sendFn, ) - session := &bssci.Session{ID: "test-session"} + session := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + }, + } dispatched, err := dispatcher.DispatchIfAvailable( context.Background(), diff --git a/KC-Core/internal/services/bssci/propagation_service_test.go b/KC-Core/internal/services/bssci/propagation_service_test.go index 1e06731..92dbeb4 100644 --- a/KC-Core/internal/services/bssci/propagation_service_test.go +++ b/KC-Core/internal/services/bssci/propagation_service_test.go @@ -209,8 +209,9 @@ func TestMultiBSPropagation(t *testing.T) { // NEW base station BS-B connects sessionBSB := propagation.BaseStationSession{ ID: "session-bs-b", - BaseStationEUI: 0x1122334455667788, // Different BS - TenantID: tenantID, + BaseStationEUI: 0x1122334455667788, + // Different BS + TenantID: tenantID, } ctx := context.Background() diff --git a/KC-Core/internal/services/bssci/session_resume_db_test.go b/KC-Core/internal/services/bssci/session_resume_db_test.go index 4143002..2332186 100644 --- a/KC-Core/internal/services/bssci/session_resume_db_test.go +++ b/KC-Core/internal/services/bssci/session_resume_db_test.go @@ -2,6 +2,7 @@ package bssciservices import ( "context" + "errors" "testing" "time" @@ -32,6 +33,8 @@ func seedResumableSession(repo *mockBaseStationSessionRepo, id, tenantID int64, protocolVersion := mioty.MIOTYProtocolVersion session := &models.BaseStationSession{ ID: id, + CanResume: true, + Encoding: "msgpack", BaseStationID: 1, TenantID: tenantID, SnBsUuid: bsUUID, @@ -39,8 +42,6 @@ func seedResumableSession(repo *mockBaseStationSessionRepo, id, tenantID int64, SnBsOpId: bsOpId, SnScOpId: scOpId, Status: models.SessionStatusDisconnected, - CanResume: true, - Encoding: "msgpack", ProtocolVersion: &protocolVersion, StartedAt: time.Now().Add(-1 * time.Hour), } @@ -62,12 +63,17 @@ func TestHandleResume_DBLookup_BySnBsUuid(t *testing.T) { dbSession := seedResumableSession(mockRepo, 42, 100, bsUUID, scUUID, 1000, -500) dbSession.OrganizationID = &orgID - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], int64Ptr(1000), int64Ptr(-500), 0x123456789ABCDEF0) - require.NoError(t, err) - require.NotNil(t, restoredSession) + require.Equal(t, bssci.ResumeCompatible, outcome.Disposition) + require.NotNil(t, outcome.Previous) + restoredSession := outcome.Previous assert.Equal(t, int64(42), restoredSession.DbSessionID) assert.Equal(t, scUUID[:], restoredSession.SessionUUID) assert.Equal(t, bsUUID[:], restoredSession.BsUUID) @@ -88,11 +94,16 @@ func TestHandleResume_AbsentCounters(t *testing.T) { bsUUID := [16]byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30} seedResumableSession(mockRepo, 43, 100, bsUUID, [16]byte{0x31}, 2000, -900) - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0xAABB) + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0xAABB) - require.NoError(t, err) - require.NotNil(t, restoredSession) + require.Equal(t, bssci.ResumeCompatible, outcome.Disposition) + require.NotNil(t, outcome.Previous) + restoredSession := outcome.Previous assert.Equal(t, int64(2000), restoredSession.LastBsOpId, "authoritative persisted counters restored when constraints absent") assert.Equal(t, int64(-900), restoredSession.LastScOpId) @@ -107,12 +118,17 @@ func TestHandleResume_StaleScCounterAccepted(t *testing.T) { bsUUID := [16]byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50} seedResumableSession(mockRepo, 44, 100, bsUUID, [16]byte{0x51}, 500, -800) - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], int64Ptr(400), int64Ptr(-700), 0xCCDD) - require.NoError(t, err) - require.NotNil(t, restoredSession) + require.Equal(t, bssci.ResumeCompatible, outcome.Disposition) + require.NotNil(t, outcome.Previous) + restoredSession := outcome.Previous assert.Equal(t, int64(-800), restoredSession.LastScOpId, "the authoritative persisted SC counter is restored, not the stale reported one") } @@ -126,12 +142,16 @@ func TestHandleResume_RequiredBsOpIdBeyondPersisted(t *testing.T) { bsUUID := [16]byte{0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70} seedResumableSession(mockRepo, 45, 100, bsUUID, [16]byte{0x71}, 1000, -500) - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], int64Ptr(1001), int64Ptr(-500), 0xEEFF) - require.ErrorIs(t, err, bssci.ErrResumeCounterMismatch) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) + assert.ErrorIs(t, outcome.Err, bssci.ErrResumeCounterMismatch) } // TestHandleResume_ClaimedScOpIdBeyondIssued verifies rejection when the base @@ -143,12 +163,16 @@ func TestHandleResume_ClaimedScOpIdBeyondIssued(t *testing.T) { bsUUID := [16]byte{0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90} seedResumableSession(mockRepo, 46, 100, bsUUID, [16]byte{0x91}, 1000, -500) - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], int64Ptr(1000), int64Ptr(-501), 0xEEFF) - require.ErrorIs(t, err, bssci.ErrResumeCounterMismatch) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) + assert.ErrorIs(t, outcome.Err, bssci.ErrResumeCounterMismatch) } // TestHandleResume_TerminatedNotResumable verifies terminated sessions never @@ -161,11 +185,14 @@ func TestHandleResume_TerminatedNotResumable(t *testing.T) { session.Status = models.SessionStatusTerminated session.CanResume = false - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) - require.NoError(t, err) - assert.Nil(t, restoredSession, "terminated sessions must not resume") + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition, "terminated sessions must not resume") } // TestHandleResume_ActiveNotResumableFromDB verifies only disconnected @@ -177,11 +204,14 @@ func TestHandleResume_ActiveNotResumableFromDB(t *testing.T) { session := seedResumableSession(mockRepo, 48, 100, bsUUID, [16]byte{0xD1}, 100, -50) session.Status = models.SessionStatusActive - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) - require.NoError(t, err) - assert.Nil(t, restoredSession, "only disconnected sessions are resumable from persistence") + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition, "only disconnected sessions are resumable from persistence") } // TestHandleResume_ShortUUID_SkipsDBLookup verifies malformed UUIDs never @@ -189,12 +219,15 @@ func TestHandleResume_ActiveNotResumableFromDB(t *testing.T) { func TestHandleResume_ShortUUID_SkipsDBLookup(t *testing.T) { svc, _ := newResumeTestService(t, 100) - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, []byte{0x01, 0x02}, + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, []byte{0x01, 0x02}, int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) - require.NoError(t, err) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) } // TestHandleResume_NilUUID_SkipsDBLookup verifies nil UUIDs never reach the @@ -202,12 +235,15 @@ func TestHandleResume_ShortUUID_SkipsDBLookup(t *testing.T) { func TestHandleResume_NilUUID_SkipsDBLookup(t *testing.T) { svc, _ := newResumeTestService(t, 100) - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, nil, + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, nil, int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) - require.NoError(t, err) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) } // TestHandleResume_NoDBMatch_ReturnNil verifies unknown UUIDs fall back to a @@ -216,12 +252,15 @@ func TestHandleResume_NoDBMatch_ReturnNil(t *testing.T) { svc, _ := newResumeTestService(t, 100) unknownUUID := [16]byte{0xDE, 0xAD, 0xBE, 0xEF} - testSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), testSession, unknownUUID[:], + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, unknownUUID[:], int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) - require.NoError(t, err) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) } // TestHandleResume_TenantIsolation verifies a session persisted under another @@ -232,18 +271,25 @@ func TestHandleResume_TenantIsolation(t *testing.T) { bsUUID := [16]byte{0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0} seedResumableSession(mockRepo, 49, 500, bsUUID, [16]byte{0xF1}, 5000, -2500) - wrongTenantSession := &bssci.Session{ResolvedTenantID: 100} - restoredSession, err := svc.HandleResume(context.Background(), wrongTenantSession, bsUUID[:], + wrongTenantSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + wrongOutcome := svc.HandleResume(context.Background(), wrongTenantSession, bsUUID[:], int64Ptr(5000), int64Ptr(-2500), 0x1122334455667788) - require.NoError(t, err) - assert.Nil(t, restoredSession, "cross-tenant UUID collisions must not resume") + assert.Equal(t, bssci.ResumeNoMatch, wrongOutcome.Disposition, "cross-tenant UUID collisions must not resume") - correctTenantSession := &bssci.Session{ResolvedTenantID: 500} - restoredSession, err = svc.HandleResume(context.Background(), correctTenantSession, bsUUID[:], + correctTenantSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 500, + }, + } + correctOutcome := svc.HandleResume(context.Background(), correctTenantSession, bsUUID[:], int64Ptr(5000), int64Ptr(-2500), 0x1122334455667788) - require.NoError(t, err) - require.NotNil(t, restoredSession) - assert.Equal(t, int64(500), restoredSession.ResolvedTenantID) + require.Equal(t, bssci.ResumeCompatible, correctOutcome.Disposition) + require.NotNil(t, correctOutcome.Previous) + assert.Equal(t, int64(500), correctOutcome.Previous.ResolvedTenantID) } func TestHydrateSessionFromDB_OrganizationIDNilSafety(t *testing.T) { @@ -275,6 +321,9 @@ func TestHydrateSessionFromDB_OrganizationIDNilSafety(t *testing.T) { dbSession := &models.BaseStationSession{ ID: 45, + CanResume: true, + Encoding: "json", + OrganizationID: tc.orgID, BaseStationID: 4, TenantID: 700, SnBsUuid: [16]byte{}, @@ -282,9 +331,6 @@ func TestHydrateSessionFromDB_OrganizationIDNilSafety(t *testing.T) { SnBsOpId: 4000, SnScOpId: -2000, Status: models.SessionStatusDisconnected, - CanResume: true, - Encoding: "json", - OrganizationID: tc.orgID, StartedAt: time.Now(), } @@ -306,6 +352,8 @@ func TestPersistSessionResumeUpdatesProtocolVersion(t *testing.T) { bsUUID := [16]byte{0x0A, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA} mockRepo.sessions[99] = &models.BaseStationSession{ ID: 99, + CanResume: true, + Encoding: "msgpack", BaseStationID: 10, TenantID: 800, SnBsUuid: bsUUID, @@ -313,17 +361,17 @@ func TestPersistSessionResumeUpdatesProtocolVersion(t *testing.T) { SnBsOpId: 3000, SnScOpId: -1500, Status: models.SessionStatusDisconnected, - CanResume: true, - Encoding: "msgpack", } session := &bssci.Session{ - ID: "resume-connection-1", - ResolvedTenantID: 800, - SessionUUID: append([]byte(nil), scUUID[:]...), - BsUUID: append([]byte(nil), bsUUID[:]...), - NegotiatedVersion: mioty.MIOTYProtocolVersion, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "resume-connection-1", + ResolvedTenantID: 800, + SessionUUID: append([]byte(nil), scUUID[:]...), + BsUUID: append([]byte(nil), bsUUID[:]...), + NegotiatedVersion: mioty.MIOTYProtocolVersion, + Encoding: "msgpack", + }, } err := svc.PersistSession(context.Background(), session, nil, true, nil) @@ -356,22 +404,26 @@ func TestMarkDisconnected_StaleConnectionDoesNotTouchNewerSession(t *testing.T) // Connection B resumed and activated the session sessionB := &bssci.Session{ - ID: "connection-b", - ResolvedTenantID: 900, - DbSessionID: 77, - SessionUUID: append([]byte(nil), scUUID[:]...), - BsUUID: append([]byte(nil), bsUUID[:]...), - NegotiatedVersion: mioty.MIOTYProtocolVersion, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "connection-b", + ResolvedTenantID: 900, + DbSessionID: 77, + SessionUUID: append([]byte(nil), scUUID[:]...), + BsUUID: append([]byte(nil), bsUUID[:]...), + NegotiatedVersion: mioty.MIOTYProtocolVersion, + Encoding: "msgpack", + }, } require.NoError(t, svc.PersistSession(context.Background(), sessionB, nil, true, nil)) require.Equal(t, models.SessionStatusActive, dbSession.Status) // Connection A's deferred cleanup runs after B took over sessionA := &bssci.Session{ - ID: "connection-a", - ResolvedTenantID: 900, - DbSessionID: 77, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "connection-a", + ResolvedTenantID: 900, + DbSessionID: 77, + }, } require.NoError(t, svc.MarkDisconnected(context.Background(), sessionA)) @@ -386,3 +438,46 @@ func TestMarkDisconnected_StaleConnectionDoesNotTouchNewerSession(t *testing.T) assert.True(t, dbSession.CanResume) assert.NotNil(t, dbSession.EndedAt) } + +// TestHandleResume_InfrastructureFailure verifies a resumable-session lookup +// failure yields ResumeInfrastructureFailure so the connect is rejected rather +// than silently degraded into a fresh session that strands the old state. +func TestHandleResume_InfrastructureFailure(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) + mockRepo.findErr = errors.New("database unavailable") + + bsUUID := [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10} + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + + assert.Equal(t, bssci.ResumeInfrastructureFailure, outcome.Disposition) + require.Error(t, outcome.Err) + assert.Nil(t, outcome.Previous, "no session is handed back on an infrastructure failure") +} + +// TestHandleResume_VersionIncompatible verifies a resumable session persisted +// under an incompatible negotiated version is rejected as inconsistent so its +// stale state can be terminated (BSSCI rev1 §4.3). +func TestHandleResume_VersionIncompatible(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) + + bsUUID := [16]byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30} + session := seedResumableSession(mockRepo, 55, 100, bsUUID, [16]byte{0x31}, 100, -50) + incompatible := "2.0.0" + session.ProtocolVersion = &incompatible + + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + NegotiatedVersion: "1.0.0", + }, + } + outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + + assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) + require.NotNil(t, outcome.Previous, "the stale session is returned for termination") +} diff --git a/KC-Core/internal/services/bssci/session_service.go b/KC-Core/internal/services/bssci/session_service.go index 01fc780..94302da 100644 --- a/KC-Core/internal/services/bssci/session_service.go +++ b/KC-Core/internal/services/bssci/session_service.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net" + "strings" "sync" "time" @@ -55,36 +56,25 @@ func (s *sessionService) resolvedTenantID(session *bssci.Session) int64 { return s.tenantID // CRITICAL: Use s.tenantID, not s.defaultTenantID } -// HandleResume validates a session resume request per BSSCI §5.3.1. Resume +// HandleResume evaluates a session resume request per BSSCI §5.3.1 against the +// DB-authoritative resumable-session lookup and returns a typed outcome. Resume // identity is snBsUuid scoped by tenant and base station EUI. The optional -// snBsOpId (minimum required known BS operation ID) and snScOpId (maximum -// known SC operation ID) constraints are asserted only when present: -// a required BS operation ID above what is persisted means the service -// center cannot satisfy the base station's minimum state; a claimed SC -// operation ID below what was issued is impossible without tampering. -// After acceptance the authoritative persisted counters are restored, not -// the constraint values reported in the connect message. -func (s *sessionService) HandleResume(ctx context.Context, session *bssci.Session, bsUUID []byte, bsOpId, scOpId *int64, bsEUI uint64) (*bssci.Session, error) { +// snBsOpId (minimum required known BS operation ID) and snScOpId (maximum known +// SC operation ID) constraints are asserted only when present. A lookup failure +// yields ResumeInfrastructureFailure (reject the connect, never a silent fresh +// session); incompatible counters or negotiated version yield ResumeInconsistent +// (terminate the stale session, then fresh); a clean match yields +// ResumeCompatible with the hydrated session, which is NOT published into any +// live registry here - the caller activates it after conCmp. +func (s *sessionService) HandleResume(ctx context.Context, session *bssci.Session, bsUUID []byte, bsOpId, scOpId *int64, bsEUI uint64) bssci.ResumeOutcome { if len(bsUUID) != 16 { - return nil, nil + return bssci.ResumeOutcome{Disposition: bssci.ResumeNoMatch} } - // In-memory lookup first: same tenant, base station EUI, and BS UUID - s.mu.RLock() - for _, sess := range s.sessionsByUUID { - if sess.BaseStationEUI != bsEUI || sess.BsUUID == nil || string(sess.BsUUID) != string(bsUUID) { - continue - } - if !s.resumeCountersConsistent(ctx, bsEUI, bsOpId, scOpId, sess.LastBsOpId, sess.LastScOpId) { - continue - } - s.mu.RUnlock() - return sess, nil - } - s.mu.RUnlock() - - // Database fallback: only a disconnected, resumable session scoped by - // tenant, base station EUI, and snBsUuid qualifies (BSSCI §5.3.1) + // DB-authoritative lookup: only a disconnected, resumable session scoped + // by tenant, base station EUI, and snBsUuid qualifies (BSSCI §5.3.1). The + // in-memory registry is never consulted for resume - an active session + // must never be handed out as resumable. var bsUUIDArray [16]byte copy(bsUUIDArray[:], bsUUID) euiBytes := make([]byte, 8) @@ -92,23 +82,73 @@ func (s *sessionService) HandleResume(ctx context.Context, session *bssci.Sessio dbSession, err := s.bsSessionRepo.FindResumableSession(ctx, s.resolvedTenantID(session), euiBytes, bsUUIDArray) if err != nil { - return nil, fmt.Errorf("resumable session lookup failed: %w", err) + // A lookup failure must not degrade into a fresh session while the + // old resumable state lingers; reject the connect instead. + return bssci.ResumeOutcome{ + Disposition: bssci.ResumeInfrastructureFailure, + Err: fmt.Errorf("resumable session lookup failed: %w", err), + } } if dbSession == nil { - return nil, nil // No match - fresh session + return bssci.ResumeOutcome{Disposition: bssci.ResumeNoMatch} } + restoredSession := s.hydrateSessionFromDB(dbSession, bsEUI) + if !s.resumeCountersConsistent(ctx, bsEUI, bsOpId, scOpId, dbSession.SnBsOpId, dbSession.SnScOpId) { - return nil, bssci.ErrResumeCounterMismatch + return bssci.ResumeOutcome{ + Disposition: bssci.ResumeInconsistent, + Previous: restoredSession, + Err: bssci.ErrResumeCounterMismatch, + } } - restoredSession := s.hydrateSessionFromDB(dbSession, bsEUI) + // A resumable session's persisted negotiated version must be compatible + // with the version selected for this connection (BSSCI rev1 §4.3: patch + // differences are compatible; major/minor must match). + if !resumeVersionCompatible(restoredSession.NegotiatedVersion, session.NegotiatedVersion) { + s.logger.WarnContext(ctx, "Resume rejected: persisted negotiated version incompatible with the selected version", + "bsEui", bsEUI, + "persistedVersion", restoredSession.NegotiatedVersion, + "selectedVersion", session.NegotiatedVersion) + return bssci.ResumeOutcome{ + Disposition: bssci.ResumeInconsistent, + Previous: restoredSession, + Err: bssci.ErrResumeCounterMismatch, + } + } - s.mu.Lock() - s.sessionsByUUID[string(restoredSession.SessionUUID)] = restoredSession - s.mu.Unlock() + // The hydrated session is NOT published into any live registry here; the + // caller activates it after conCmp. + return bssci.ResumeOutcome{Disposition: bssci.ResumeCompatible, Previous: restoredSession} +} + +// resumeVersionCompatible reports whether a persisted negotiated version can +// resume under a newly selected version. An empty version on either side +// (legacy rows, or a caller that has not recorded the negotiated version) +// cannot assert incompatibility and is treated as compatible; otherwise the +// major and minor components must match (BSSCI rev1 §4.3 - patch differences +// are compatible). +func resumeVersionCompatible(persisted, selected string) bool { + if persisted == "" || selected == "" || persisted == selected { + return true + } + pMaj, pMin, pOK := majorMinor(persisted) + sMaj, sMin, sOK := majorMinor(selected) + if !pOK || !sOK { + return false + } + return pMaj == sMaj && pMin == sMin +} - return restoredSession, nil +// majorMinor parses the major and minor components of a "major.minor.patch" +// version string. +func majorMinor(v string) (string, string, bool) { + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return "", "", false + } + return parts[0], parts[1], true } // resumeCountersConsistent applies the BSSCI §5.3.1 resume constraints @@ -164,20 +204,21 @@ func (s *sessionService) hydrateSessionFromDB(dbSession *models.BaseStationSessi } return &bssci.Session{ - DbSessionID: dbSession.ID, // int64 → int64 - SessionUUID: dbSession.SnScUuid[:], // [16]byte → []byte - BsUUID: dbSession.SnBsUuid[:], // [16]byte → []byte - LastBsOpId: dbSession.SnBsOpId, // int64 → int64 (BSSCI §3.2) - LastScOpId: dbSession.SnScOpId, // int64 → int64 (BSSCI §3.2) - Encoding: dbSession.Encoding, // string → string (BSSCI §1) - ClientVersion: clientVersion, // string → string (BSSCI §4-4.5) - NegotiatedVersion: negotiatedVersion, // string → string (BSSCI §4-4.5) - OrganizationID: orgID, // *uuid.UUID → uuid.UUID (nil-safe) - ResolvedTenantID: dbSession.TenantID, // int64 → int64 - BaseStationEUI: bsEUI, // From parameter (validated by caller) - IsResumed: true, // Mark as resumed session (BSSCI §5.3.2) - // Lifecycle fields (ID, Conn, Connected, LastSeen, etc.) initialized by caller (handleConnect) - // ActiveVMTypes, stopStatus channel created by caller as needed + ProtocolSessionState: bssci.ProtocolSessionState{ + DbSessionID: dbSession.ID, // int64 → int64 + SessionUUID: dbSession.SnScUuid[:], // [16]byte → []byte + BsUUID: dbSession.SnBsUuid[:], // [16]byte → []byte + LastBsOpId: dbSession.SnBsOpId, // int64 → int64 (BSSCI §3.2) + LastScOpId: dbSession.SnScOpId, // int64 → int64 (BSSCI §3.2) + Encoding: dbSession.Encoding, // string → string (BSSCI §1) + ClientVersion: clientVersion, // string → string (BSSCI §4-4.5) + NegotiatedVersion: negotiatedVersion, // string → string (BSSCI §4-4.5) + OrganizationID: orgID, // *uuid.UUID → uuid.UUID (nil-safe) + ResolvedTenantID: dbSession.TenantID, // int64 → int64 + BaseStationEUI: bsEUI, // From parameter (validated by caller) + IsResumed: true, // Mark as resumed session (BSSCI §5.3.2) + }, + // Transport fields (Conn, Connected, LastSeen, etc.) initialized by caller (handleConnect) } } @@ -209,18 +250,18 @@ func (s *sessionService) PersistSession(ctx context.Context, session *bssci.Sess // New session - terminate any stale session first per BSSCI §3 "new session starts, discarding state" dbSession, err := s.bsSessionRepo.GetActiveSessionByBaseStation(ctx, s.resolvedTenantID(session), baseStation.ID) if err == nil && dbSession != nil { - // Terminate stale session before creating new one + // A failure to retire the stale session must abort activation: + // creating a new session on top would leave two active sessions. if err := s.bsSessionRepo.TerminateSession(ctx, s.resolvedTenantID(session), dbSession.ID); err != nil { s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToTerminateStaleSession, "error", err, "staleSessionID", dbSession.ID, "baseStationEui", session.BaseStationEUI) - // Continue with new session creation despite termination error - } else { - s.logger.InfoContext(ctx, bssci.LogBSSCITerminatedStaleSession, - "staleSessionID", dbSession.ID, - "baseStationEui", session.BaseStationEUI) + return fmt.Errorf("terminate stale session before activation: %w", err) } + s.logger.InfoContext(ctx, bssci.LogBSSCITerminatedStaleSession, + "staleSessionID", dbSession.ID, + "baseStationEui", session.BaseStationEUI) } // No existing session (or terminated) - create new one via repository @@ -452,9 +493,14 @@ func (s *sessionService) RemoveSession(session *bssci.Session) { return } - // Use same key format as StoreSessionByUUID (line 242) uuidKey := string(session.SessionUUID) - delete(s.sessionsByUUID, uuidKey) + // Connection-identity guard: a reconnect may have already replaced this + // UUID with a newer live session. Remove only when the stored entry is + // still this exact connection, so connection A's cleanup cannot evict + // connection B. + if stored, ok := s.sessionsByUUID[uuidKey]; ok && stored.ID == session.ID { + delete(s.sessionsByUUID, uuidKey) + } } // UpdateEncoding persists the negotiated message encoding to the database diff --git a/KC-Core/internal/services/bssci/status_service.go b/KC-Core/internal/services/bssci/status_service.go index a7d4cdf..d1b0149 100644 --- a/KC-Core/internal/services/bssci/status_service.go +++ b/KC-Core/internal/services/bssci/status_service.go @@ -37,17 +37,15 @@ func NewStatusService(pendingOps *map[bssci.SessionOpKey]*bssci.PendingOperation // RecordPendingOperation stores operation in map + DB using SessionOpKey composite key // Persists to DB table bssci_pending_operations and mirrors into the shared cache func (s *statusService) RecordPendingOperation(ctx context.Context, session *bssci.Session, opId int64, op *bssci.PendingOperation, dbSessionID int64) error { - // 1. Update in-memory cache using SessionOpKey composite key key := bssci.SessionOpKey{ SessionID: session.ID, OperationID: opId, } - s.mu.Lock() - (*s.pendingOps)[key] = op - s.mu.Unlock() - // 2. Convert in-memory types to repository types - // Marshal Message map → operation_data JSON + // The database is authoritative: persist first, then mirror into the cache. + // If the durable write fails the operation is not recorded anywhere, so a + // caller that treats a persistence failure as fatal cannot send an + // operation whose recovery record does not exist. operationData, err := json.Marshal(op.Message) if err != nil { s.logger.ErrorContext(ctx, "Failed to marshal pending operation", @@ -56,12 +54,11 @@ func (s *statusService) RecordPendingOperation(ctx context.Context, session *bss return err } - // Marshal Metadata map → metadata JSON (nullable) var metadataJSON json.RawMessage if op.Metadata != nil { metadataBytes, err := json.Marshal(op.Metadata) if err != nil { - s.logger.Error("Failed to marshal pending operation metadata", + s.logger.ErrorContext(ctx, "Failed to marshal pending operation metadata", "error", err, "opId", opId) return err @@ -69,15 +66,21 @@ func (s *statusService) RecordPendingOperation(ctx context.Context, session *bss metadataJSON = json.RawMessage(metadataBytes) } - // 3. Persist via repository - USE CALLER'S CONTEXT - return s.repo.Create(ctx, &interfaces.PendingOperationRequest{ + if err := s.repo.Create(ctx, &interfaces.PendingOperationRequest{ SessionID: dbSessionID, // Use DB session ID (not in-memory session.ID) OperationID: opId, OperationType: op.OperationType, EndpointEUI: op.Endpoint, OperationData: json.RawMessage(operationData), Metadata: metadataJSON, // nil → SQL NULL - }) + }); err != nil { + return err + } + + s.mu.Lock() + (*s.pendingOps)[key] = op + s.mu.Unlock() + return nil } // GetPendingOperation retrieves operation from REAL map using SessionOpKey composite key @@ -98,9 +101,14 @@ func (s *statusService) GetPendingOperation(session *bssci.Session, opId int64) return op, nil } -// RemovePendingOperation cleans map + DB using SessionOpKey composite key +// RemovePendingOperation cleans DB + map using SessionOpKey composite key. +// The database is removed first: if the durable delete fails, the cache entry +// (and its live retry state) is preserved so the operation is not lost. func (s *statusService) RemovePendingOperation(ctx context.Context, session *bssci.Session, opId int64) error { - // 1. Remove from cache using SessionOpKey + if err := s.repo.DeleteBySessionAndOperation(ctx, session.DbSessionID, opId); err != nil { + return err + } + key := bssci.SessionOpKey{ SessionID: session.ID, OperationID: opId, @@ -108,9 +116,7 @@ func (s *statusService) RemovePendingOperation(ctx context.Context, session *bss s.mu.Lock() delete(*s.pendingOps, key) s.mu.Unlock() - - // 2. Remove from DB via repository - return s.repo.DeleteBySessionAndOperation(ctx, session.DbSessionID, opId) + return nil } // ExtractQueueMetadata extracts endpoint EUI, queue ID, and tenant ID from pending operation metadata. diff --git a/KC-Core/internal/services/bssci/status_service_integration_test.go b/KC-Core/internal/services/bssci/status_service_integration_test.go index a785c04..5f3c0c4 100644 --- a/KC-Core/internal/services/bssci/status_service_integration_test.go +++ b/KC-Core/internal/services/bssci/status_service_integration_test.go @@ -64,16 +64,22 @@ func TestStatusServiceMultiSessionIsolation(t *testing.T) { // Create three sessions from different base stations session1 := &bssci.Session{ - ID: "session-bs1", - DbSessionID: 1001, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "session-bs1", + DbSessionID: 1001, + }, } session2 := &bssci.Session{ - ID: "session-bs2", - DbSessionID: 1002, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "session-bs2", + DbSessionID: 1002, + }, } session3 := &bssci.Session{ - ID: "session-bs3", - DbSessionID: 1003, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "session-bs3", + DbSessionID: 1003, + }, } ctx := context.Background() @@ -199,8 +205,10 @@ func TestStatusServiceCachePopulationForNewOperations(t *testing.T) { statusSvc := NewStatusService(&pendingOps, &mu, mockRepo, log) session := &bssci.Session{ - ID: "test-session", - DbSessionID: 2001, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + DbSessionID: 2001, + }, } ctx := context.Background() @@ -259,8 +267,10 @@ func TestStatusServiceSingleWriterPattern(t *testing.T) { statusSvc := NewStatusService(&pendingOps, &mu, mockRepo, log) session := &bssci.Session{ - ID: "single-writer-test", - DbSessionID: 3001, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "single-writer-test", + DbSessionID: 3001, + }, } ctx := context.Background() diff --git a/KC-Core/internal/services/bssci/test_helpers.go b/KC-Core/internal/services/bssci/test_helpers.go index 9fdd98d..a4ec236 100644 --- a/KC-Core/internal/services/bssci/test_helpers.go +++ b/KC-Core/internal/services/bssci/test_helpers.go @@ -124,6 +124,7 @@ func (m *mockBaseStationRepo) ListAllLocations(_ context.Context) ([]*models.Bas type mockBaseStationSessionRepo struct { nextID int64 sessions map[int64]*models.BaseStationSession + findErr error // when set, FindResumableSession returns it (infra-failure tests) mu sync.Mutex } @@ -372,6 +373,10 @@ func (m *mockBaseStationSessionRepo) FindResumableSession(_ context.Context, ten m.mu.Lock() defer m.mu.Unlock() + if m.findErr != nil { + return nil, m.findErr + } + for _, session := range m.sessions { if session.TenantID != tenantID { continue diff --git a/KC-Core/pkg/bssci/attach_detach_profile_test.go b/KC-Core/pkg/bssci/attach_detach_profile_test.go index 0f1ec45..b327895 100644 --- a/KC-Core/pkg/bssci/attach_detach_profile_test.go +++ b/KC-Core/pkg/bssci/attach_detach_profile_test.go @@ -265,14 +265,16 @@ func newProfileTestFixture(t *testing.T, initialProfile string) *profileTestFixt testConn := &bsscitest.TestConn{Encoding: "json"} session := &Session{ - ID: "test-profile-session", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: testConn, - SessionUUID: uuidBytes(), - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-profile-session", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + SessionUUID: uuidBytes(), + HandshakeComplete: true, + }, + Conn: testConn, } return &profileTestFixture{ @@ -526,14 +528,16 @@ func TestDetachProfileGuardNonRegression(t *testing.T) { testConn := &bsscitest.TestConn{Encoding: "json"} session := &Session{ - ID: "test-detach-profile-session", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: testConn, - SessionUUID: uuidBytes(), - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-detach-profile-session", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + SessionUUID: uuidBytes(), + HandshakeComplete: true, + }, + Conn: testConn, } // Build detach message with empty profile diff --git a/KC-Core/pkg/bssci/attach_propagate_integration_test.go b/KC-Core/pkg/bssci/attach_propagate_integration_test.go index ad07ee7..25ff481 100644 --- a/KC-Core/pkg/bssci/attach_propagate_integration_test.go +++ b/KC-Core/pkg/bssci/attach_propagate_integration_test.go @@ -497,13 +497,15 @@ func TestAttachPropagateCompletionIntegration_WithPendingOp(t *testing.T) { // Create mock connection mockConn := &attPrpTestConn{} session := &bssci.Session{ - ID: "test-attprp-integration", - BaseStationEUI: testBsEui, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: testTenantID, - DbSessionID: 1, // Required for pending operation processing + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-attprp-integration", + BaseStationEUI: testBsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: testTenantID, + DbSessionID: 1, // Required for pending operation processing + }, + Conn: mockConn, } server.RegisterSession(session) @@ -617,12 +619,14 @@ func TestAttachPropagateCompletionIntegration_NoPendingOp(t *testing.T) { // Create mock connection mockConn := &attPrpTestConn{} session := &bssci.Session{ - ID: "test-attprp-no-pendingop", - BaseStationEUI: testBsEui, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: testTenantID, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-attprp-no-pendingop", + BaseStationEUI: testBsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: testTenantID, + }, + Conn: mockConn, } server.RegisterSession(session) @@ -798,13 +802,15 @@ func TestHandleAttachPropagateResponse_Rejected(t *testing.T) { mockConn := &attPrpTestConn{} session := &bssci.Session{ - ID: "test-attprp-rejected", - BaseStationEUI: testBsEui, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: testTenantID, - DbSessionID: 1, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-attprp-rejected", + BaseStationEUI: testBsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: testTenantID, + DbSessionID: 1, + }, + Conn: mockConn, } server.RegisterSession(session) diff --git a/KC-Core/pkg/bssci/attach_replay_protection_test.go b/KC-Core/pkg/bssci/attach_replay_protection_test.go index 601c1ed..8519e5f 100644 --- a/KC-Core/pkg/bssci/attach_replay_protection_test.go +++ b/KC-Core/pkg/bssci/attach_replay_protection_test.go @@ -101,14 +101,16 @@ func TestAttachReplayProtection_RejectReplay(t *testing.T) { // Create TestConn and session testConn := &bsscitest.TestConn{Encoding: "json"} session := &Session{ - ID: "test-session-replay", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: testConn, - SessionUUID: uuidBytes(), - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-replay", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + SessionUUID: uuidBytes(), + HandshakeComplete: true, + }, + Conn: testConn, } const opID = int64(1) @@ -219,14 +221,16 @@ func TestAttachReplayProtection_RejectLowerCounter(t *testing.T) { testConn := &bsscitest.TestConn{Encoding: "json"} session := &Session{ - ID: "test-session-lower", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: testConn, - SessionUUID: uuidBytes(), - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-lower", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + SessionUUID: uuidBytes(), + HandshakeComplete: true, + }, + Conn: testConn, } signFloats := make([]interface{}, 4) @@ -315,14 +319,16 @@ func TestAttachReplayProtection_RolloverEdgeCase(t *testing.T) { testConn := &bsscitest.TestConn{Encoding: "json"} session := &Session{ - ID: "test-session-rollover", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: testConn, - SessionUUID: uuidBytes(), - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-rollover", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + SessionUUID: uuidBytes(), + HandshakeComplete: true, + }, + Conn: testConn, } signFloats := make([]interface{}, 4) @@ -406,14 +412,16 @@ func TestAttachReplayProtection_FirstAttachNilCounter(t *testing.T) { testConn := &bsscitest.TestConn{Encoding: "json"} session := &Session{ - ID: "test-session-first", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: testConn, - SessionUUID: uuidBytes(), - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-first", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + SessionUUID: uuidBytes(), + HandshakeComplete: true, + }, + Conn: testConn, } signFloats := make([]interface{}, 4) diff --git a/KC-Core/pkg/bssci/connect_handler_test.go b/KC-Core/pkg/bssci/connect_handler_test.go index 74b8155..fb9a14c 100644 --- a/KC-Core/pkg/bssci/connect_handler_test.go +++ b/KC-Core/pkg/bssci/connect_handler_test.go @@ -206,11 +206,13 @@ func TestHandleConnectCompleteUnregisteredBaseStationUsesErrorCatalog(t *testing bsEUI := bssci.TestBsEui01 mockConn := &mockConnForConnect{} session := &bssci.Session{ - ID: "test-session-1", - BaseStationEUI: bsEUI, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session-1", + BaseStationEUI: bsEUI, + SessionUUID: make([]byte, 16), // Required for handleConnectComplete + }, UserProvidedName: "Unregistered BS", Conn: mockConn, - SessionUUID: make([]byte, 16), // Required for handleConnectComplete } // Registration is validated during the connect request, before conRsp @@ -275,13 +277,17 @@ func TestConnectAdoptsRegisteredTenantCommunityFallback(t *testing.T) { mockConn := &mockConnForConnect{} session := &bssci.Session{ - ID: "test-cross-tenant", - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-cross-tenant", + BaseStationEUI: bssci.TestBsEui01, + SessionUUID: make([]byte, 16), + ResolvedTenantID: 1, + // Starts with server default + IsResumed: true, + }, UserProvidedName: "External BS", Conn: mockConn, - SessionUUID: make([]byte, 16), - ResolvedTenantID: 1, // Starts with server default - IsResumed: true, // Skip PersistSession DbSessionID assignment (avoids nil storage in loadPendingOperations) + // Skip PersistSession DbSessionID assignment (avoids nil storage in loadPendingOperations) } connectData := map[string]interface{}{ @@ -339,11 +345,14 @@ func TestConnectTenantMismatchRejected(t *testing.T) { mockConn := &mockConnForConnect{} session := &bssci.Session{ - ID: "test-tenant-mismatch", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - SessionUUID: make([]byte, 16), - ResolvedTenantID: 1, // Certificate resolved to tenant 1; BS registered under 4 + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-tenant-mismatch", + BaseStationEUI: bssci.TestBsEui01, + SessionUUID: make([]byte, 16), + ResolvedTenantID: 1, + }, + Conn: mockConn, + // Certificate resolved to tenant 1; BS registered under 4 } connectData := map[string]interface{}{ @@ -399,13 +408,15 @@ func TestConnectCompleteDefaultTenantUnchanged(t *testing.T) { mockConn := &mockConnForConnect{} session := &bssci.Session{ - ID: "test-default-tenant", - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-default-tenant", + BaseStationEUI: bssci.TestBsEui01, + SessionUUID: make([]byte, 16), + ResolvedTenantID: 1, + IsResumed: true, + }, UserProvidedName: "Local BS", Conn: mockConn, - SessionUUID: make([]byte, 16), - ResolvedTenantID: 1, - IsResumed: true, } connectData := map[string]interface{}{ @@ -450,11 +461,13 @@ func TestConnectHandler_ValidGeoLocation_PersistsToDB(t *testing.T) { mockConn := &mockConnForConnect{} session := &bssci.Session{ - ID: "test-geo-connect-valid", - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-connect-valid", + BaseStationEUI: bssci.TestBsEui01, + SessionUUID: make([]byte, 16), + }, UserProvidedName: "GeoLocation BS", Conn: mockConn, - SessionUUID: make([]byte, 16), } // handleConnect parses geoLocation from conRsp data into session.GeoLocation @@ -522,11 +535,13 @@ func TestConnectHandler_InvalidGeoLocation_NoPersistence(t *testing.T) { mockConn := &mockConnForConnect{} session := &bssci.Session{ - ID: "test-geo-connect-invalid", - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-connect-invalid", + BaseStationEUI: bssci.TestBsEui01, + SessionUUID: make([]byte, 16), + }, UserProvidedName: "Invalid Geo BS", Conn: mockConn, - SessionUUID: make([]byte, 16), } // geoLocation as a string (wrong type — should be array) @@ -581,11 +596,13 @@ func TestConnectHandler_OutOfRangeGeoLocation_NoPersistence(t *testing.T) { mockConn := &mockConnForConnect{} session := &bssci.Session{ - ID: "test-geo-connect-outofrange", - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-connect-outofrange", + BaseStationEUI: bssci.TestBsEui01, + SessionUUID: make([]byte, 16), + }, UserProvidedName: "OutOfRange Geo BS", Conn: mockConn, - SessionUUID: make([]byte, 16), } // Latitude = 200.0 exceeds LatitudeMax (90.0) diff --git a/KC-Core/pkg/bssci/constants.go b/KC-Core/pkg/bssci/constants.go index c09c240..03cfc83 100644 --- a/KC-Core/pkg/bssci/constants.go +++ b/KC-Core/pkg/bssci/constants.go @@ -24,6 +24,9 @@ const ( // defaultOperationAckTimeout bounds handshake waits (conCmp/errorAck after conRsp or a connect-stage error) defaultOperationAckTimeout = 30 * time.Second + // defaultConnectionEstablishmentTimeout bounds a fresh connection before its con arrives + defaultConnectionEstablishmentTimeout = 30 * time.Second + // defaultDuplicateWindow is the uplink deduplication window per MIOTY spec defaultDuplicateWindow = 5 * time.Minute diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index e4be2c7..165794d 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -20,14 +20,49 @@ type VersionNegotiator interface { Negotiate(ctx context.Context, requested string) (selected string, err error) } +// ResumeDisposition classifies the outcome of a session resume attempt so the +// connect flow never silently degrades an infrastructure failure or an +// inconsistent counter state into a fresh session. +type ResumeDisposition int + +const ( + // ResumeNoMatch: no resumable session exists; start a fresh session. + ResumeNoMatch ResumeDisposition = iota + // ResumeCompatible: a resumable session was found and its constraints + // hold; Previous carries the authoritative persisted session state. + ResumeCompatible + // ResumeInconsistent: a resumable session exists but the reported + // counters or negotiated version are incompatible. The caller must + // atomically terminate it (can_resume=false, pending ops removed) before + // starting a fresh session. + ResumeInconsistent + // ResumeInfrastructureFailure: the resume lookup itself failed (e.g. a + // database outage). The caller must reject the connect, never proceed + // with a fresh session that would strand the old resumable state. + ResumeInfrastructureFailure +) + +// ResumeOutcome is the typed result of HandleResume. +type ResumeOutcome struct { + Disposition ResumeDisposition + // Previous is the resumable session (ResumeCompatible) or the stale + // session to terminate (ResumeInconsistent); nil otherwise. + Previous *Session + // Err carries the underlying cause for ResumeInfrastructureFailure and + // the mismatch detail for ResumeInconsistent. + Err error +} + // SessionService handles connect/resume with REAL persistence // Preserves: sessionsByUUID map, DbSessionId, HandshakeComplete, DB persistence type SessionService interface { - // HandleResume validates a session resume request (BSSCI §5.3.1): resume - // identity is snBsUuid scoped by tenant and base station EUI. The optional - // snBsOpId/snScOpId constraints are pointers - absent means the constraint - // is not asserted. Returns the resumable session or nil for a fresh start. - HandleResume(ctx context.Context, session *Session, bsUUID []byte, bsOpId, scOpId *int64, bsEUI uint64) (*Session, error) + // HandleResume evaluates a session resume request (BSSCI §5.3.1) against + // the DB-authoritative resumable-session lookup (tenant + base station EUI + // + snBsUuid + disconnected + can_resume). The optional snBsOpId/snScOpId + // constraints are pointers - absent means the constraint is not asserted. + // It never publishes the hydrated session into any live registry; the + // caller activates it. Returns a typed ResumeOutcome. + HandleResume(ctx context.Context, session *Session, bsUUID []byte, bsOpId, scOpId *int64, bsEUI uint64) ResumeOutcome // PersistSession writes to basestation_sessions table // Uses real *sql.DB, updates DbSessionId, handles resume UPDATE vs new INSERT diff --git a/KC-Core/pkg/bssci/detach_integration_test.go b/KC-Core/pkg/bssci/detach_integration_test.go index e32904a..095b836 100644 --- a/KC-Core/pkg/bssci/detach_integration_test.go +++ b/KC-Core/pkg/bssci/detach_integration_test.go @@ -227,13 +227,16 @@ func newDetachTestEnv(t *testing.T, cfg *Config, endpoint *models.EndPoint) *det sessionTenant = endpoint.TenantID } session := &Session{ - ID: "session-detach", - BaseStationEUI: 0xABCDEF1234567890, - ResolvedTenantID: sessionTenant, - DbSessionID: 1, // Non-zero to enable database persistence for crash recovery tests - Encoding: EncodingJSON, - Conn: conn, - SessionUUID: uuidBytes(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-detach", + BaseStationEUI: 0xABCDEF1234567890, + ResolvedTenantID: sessionTenant, + // Non-zero to enable database persistence for crash recovery tests + DbSessionID: 1, + SessionUUID: uuidBytes(), + Encoding: EncodingJSON, + }, + Conn: conn, } return &detachTestEnv{ diff --git a/KC-Core/pkg/bssci/detach_propagate_complete_test.go b/KC-Core/pkg/bssci/detach_propagate_complete_test.go index 571c278..677631a 100644 --- a/KC-Core/pkg/bssci/detach_propagate_complete_test.go +++ b/KC-Core/pkg/bssci/detach_propagate_complete_test.go @@ -485,13 +485,16 @@ func TestDetachPropagateCompletionIntegration_MessagePersistence(t *testing.T) { // Create mock connection mockConn := &detPrpTestConn{} session := &bssci.Session{ - ID: "test-detprp-integration", - BaseStationEUI: testBsEui, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: testTenantID, - DbSessionID: 1, // Required for pending operation processing + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-detprp-integration", + BaseStationEUI: testBsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: testTenantID, + DbSessionID: 1, + }, + Conn: mockConn, + // Required for pending operation processing } server.RegisterSession(session) @@ -607,12 +610,14 @@ func TestDetachPropagateCompletionIntegration_NoPendingOp(t *testing.T) { // Create mock connection mockConn := &detPrpTestConn{} session := &bssci.Session{ - ID: "test-detprp-no-pendingop", - BaseStationEUI: testBsEui, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: testTenantID, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-detprp-no-pendingop", + BaseStationEUI: testBsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: testTenantID, + }, + Conn: mockConn, } server.RegisterSession(session) @@ -673,13 +678,15 @@ func TestHandleDetachPropagateResponse_Rejected(t *testing.T) { mockConn := &detPrpTestConn{} session := &bssci.Session{ - ID: "test-detprp-rejected", - BaseStationEUI: testBsEui, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: testTenantID, - DbSessionID: 1, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-detprp-rejected", + BaseStationEUI: testBsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: testTenantID, + DbSessionID: 1, + }, + Conn: mockConn, } server.RegisterSession(session) @@ -846,13 +853,15 @@ func TestSendDetachPropagateComplete_SendFailure(t *testing.T) { mockConn := &detPrpFailingWriteConn{} session := &bssci.Session{ - ID: "test-detprp-send-failure", - BaseStationEUI: testBsEui, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: testTenantID, - DbSessionID: 1, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-detprp-send-failure", + BaseStationEUI: testBsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: testTenantID, + DbSessionID: 1, + }, + Conn: mockConn, } server.RegisterSession(session) diff --git a/KC-Core/pkg/bssci/dl_rx_status_test.go b/KC-Core/pkg/bssci/dl_rx_status_test.go index ea18d2d..b354d48 100644 --- a/KC-Core/pkg/bssci/dl_rx_status_test.go +++ b/KC-Core/pkg/bssci/dl_rx_status_test.go @@ -26,10 +26,15 @@ func TestDLRXStatusHandlerValidation(t *testing.T) { // Create test session with mock connection session := &bssci.Session{ - BaseStationEUI: bssci.TestBsEui01, - DbSessionID: 12345, // int64, not string - Encoding: "msgpack", // Session encoding must match TestConn encoding (Issue #3-4 Fix A2) - Conn: &testutil.TestConn{Encoding: "msgpack"}, // Add mock connection to prevent nil pointer + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: bssci.TestBsEui01, + DbSessionID: 12345, + // int64, not string + Encoding: "msgpack", + }, + // Session encoding must match TestConn encoding (Issue #3-4 Fix A2) + Conn: &testutil.TestConn{Encoding: "msgpack"}, + // Add mock connection to prevent nil pointer } tests := []struct { @@ -153,41 +158,6 @@ func TestDLRXStatusPersistence(t *testing.T) { // 4. Timestamp generation } -// TestDLRXStatusQueryComplete tests cleanup of pending operations -func TestDLRXStatusQueryComplete(t *testing.T) { - logger := logger.NewNop() - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := bssci.CreateTestServices(logger, nil) - server := bssci.NewTestServer(logger, mockStorage, nil, 1, - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) - - // Add a pending operation (access via unexported field now impossible from external test) - // This test needs to be revised or moved to internal package - - // Create test session - session := &bssci.Session{ - BaseStationEUI: bssci.TestBsEui01, - DbSessionID: 12345, - } - - msg := &bssci.Message{ - Command: "dlRxStatQryCmp", - OpId: int64(-100), - } - - // Call handler - err := server.CallHandleDLRXStatusQueryComplete(session, msg, nil) - - // Without database, this will fail, but we're testing it doesn't panic - // In a real integration test with database, we'd verify cleanup - if err != nil { - // Expected error due to nil database - t.Logf("Expected error without database: %v", err) - } - - // Verify memory cleanup (if handler got that far) - // In a real test with DB, we'd assert the operation was removed -} - // TestHandlerRegistration verifies all DL RX status handlers are registered func TestHandlerRegistration(t *testing.T) { // Create real services for test @@ -205,7 +175,6 @@ func TestHandlerRegistration(t *testing.T) { "dlRxStatRsp", "dlRxStatCmp", "dlRxStatQryRsp", - "dlRxStatQryCmp", } for _, cmd := range expectedHandlers { @@ -213,8 +182,9 @@ func TestHandlerRegistration(t *testing.T) { assert.NotNil(t, server.Handlers()[cmd], "Handler for %s should not be nil", cmd) } - // Verify dlRxStatQry is NOT registered (SC-initiated only) + // Verify SC-initiated commands and SC-sent completions are NOT registered inbound assert.NotContains(t, server.Handlers(), "dlRxStatQry", "dlRxStatQry should not have a handler (SC-initiated)") + assert.NotContains(t, server.Handlers(), "dlRxStatQryCmp", "dlRxStatQryCmp is SC-sent; no inbound handler") } // TestValidationCommandList verifies DL RX commands are in validation list diff --git a/KC-Core/pkg/bssci/downlink_handlers.go b/KC-Core/pkg/bssci/downlink_handlers.go index 5a298c2..2308935 100644 --- a/KC-Core/pkg/bssci/downlink_handlers.go +++ b/KC-Core/pkg/bssci/downlink_handlers.go @@ -89,31 +89,18 @@ func (s *Server) handleDLDataQueueResponse(_ *Server, session *Session, msg *Mes } } - // Send dlDataQueCmp to complete the three-way handshake via queue serializer + // The service center completes its own SC-initiated dlDataQue operation + // (BSSCI §3.12): it sends dlDataQueCmp and finalizes the pending operation. + // A spec-compliant base station never returns dlDataQueCmp, so the pending + // row is removed here or it leaks. complete := s.queueSerializer.BuildDLDataQueueComplete(msg.OpId) - return s.sendMessage(session, complete) -} - -// handleDLDataQueueComplete handles dlDataQueCmp from base station -func (s *Server) handleDLDataQueueComplete(_ *Server, session *Session, msg *Message, _ map[string]interface{}) error { - if session == nil { - return fmt.Errorf("%s", ResolveErrorMessage(errSessionNil)) + if err := s.sendMessage(session, complete); err != nil { + return err } - - s.logger.InfoContext(s.sessionContext(session), LogBSSCIDLDataQueueOperationCompleted, - "bsEui", session.BaseStationEUI, - "opId", msg.OpId) - - // Remove pending operation from database - // Delegate to StatusService for map + DB cleanup - // BSSCI §§5.11-5.12.3 Gap 1: Use removePendingOperation helper (has dual-path logic) if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationFromDB, - "sessionID", session.DbSessionID, - "opId", msg.OpId, - "error", err) + s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationFromDatabase, + "error", err, "opId", msg.OpId) } - return nil } @@ -164,7 +151,10 @@ func (s *Server) handleDLDataResult(_ *Server, session *Session, msg *Message, d if s.mqttPublisher != nil && s.tenantResolver != nil && dlResult.QueId > 0 && dlResult.QueId <= uint64(math.MaxInt64) { if tenantStr, resolveErr := s.tenantResolver.ResolveTenant(ctx, int64(dlResult.QueId)); resolveErr == nil { if tid, parseErr := strconv.ParseInt(tenantStr, 10, 64); parseErr == nil && s.orgResolver != nil { - if orgUUID, orgErr := s.orgResolver.GetDefaultOrgForTenant(s.safeCtx(), tid); orgErr == nil { + // A Nil org UUID means the organization is unresolved; its + // string form is non-empty, so guard on the value not the + // string, or the publish would fire for an unresolved org. + if orgUUID, orgErr := s.orgResolver.GetDefaultOrgForTenant(s.safeCtx(), tid); orgErr == nil && orgUUID != uuid.Nil { mqttOrgStr = orgUUID.String() } } @@ -738,104 +728,17 @@ func (s *Server) handleDLDataRevokeResponse(_ *Server, session *Session, msg *Me } } - // Send response message returned by service - return s.sendMessage(session, responseMsg) -} - -// handleDLDataRevokeComplete handles dlDataRevCmp from base station -func (s *Server) handleDLDataRevokeComplete(_ *Server, session *Session, msg *Message, _ map[string]interface{}) error { - if session == nil { - return fmt.Errorf("%s", ResolveErrorMessage(errSessionNil)) - } - - // Get queId from pending operation metadata before cleanup - // StatusService is the only path for pending operation storage - var queId uint64 - pendingOp, err := s.statusSvc.GetPendingOperation(session, int64(msg.OpId)) - if err != nil { - s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToGetPendingOperation, - "opId", msg.OpId, "error", err) - } - if pendingOp != nil && pendingOp.Metadata != nil { - if qid, ok := pendingOp.Metadata["queId"].(uint64); ok { - queId = qid - } else if qid, ok := pendingOp.Metadata["queId"].(int64); ok { - // Range guard for int64 -> uint64 conversion - if qid >= 0 { - queId = uint64(qid) - } else { - s.logger.WarnContext(s.sessionContext(session), LogBSSCINegativeQueueIDInMetadata, - "queId", qid, - "opId", msg.OpId) - } - } else if qid, ok := pendingOp.Metadata["queId"].(float64); ok { - queId = uint64(qid) - } - } - - // Resolve owner tenant FIRST - REQUIRED - var ownerTenantStr string - if queId > 0 { - ctx := s.sessionContext(session) - tidStr, err := s.tenantResolver.ResolveTenant(ctx, int64(queId)) - if err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCICannotResolveTenantForRevokeComplete, - "queue_id", queId, - "opId", msg.OpId, - "error", err) - - // Still clean up pending operation to avoid stuck state - // BSSCI §§5.11-5.12.3 Gap 1: Use StatusService for pending operation removal - if cleanupErr := s.removePendingOperation(session, msg.OpId); cleanupErr != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperation, - "error", cleanupErr, "opId", msg.OpId) - } - - return s.sendError(session, msg.OpId, POSIX_EPROTO, - ResolveErrorMessage(errCannotResolveTenantForQueue)) - } - ownerTenantStr = tidStr - - // Queue-to-tenant mapping already unregistered by ProcessRevokeResponse in handleDLDataRevokeResponse - } else { - // No queue ID - can't proceed but not an error - s.logger.WarnContext(s.sessionContext(session), LogBSSCINoQueueIDInRevokeComplete, - "opId", msg.OpId) - // Still clean up pending operation - // BSSCI §§5.11-5.12.3 Gap 1: Use StatusService for pending operation removal - if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperation, - "error", err, "opId", msg.OpId) - } - return nil - } - - // Clear base station ownership in database (set to NULL) - if s.storage != nil && queId > 0 { - ctx := s.sessionContext(session) - // Use resolved tenant - tenantIDStr := ownerTenantStr - if err := s.storage.MIOTYDownlinks().UpdateDownlinkBaseStation(ctx, queId, tenantIDStr, 0); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToClearDownlinkBaseStationOwnership, - "queId", queId, - "error", err) - } + // The service center completes its own SC-initiated dlDataRev operation + // (BSSCI §3.13): it sends dlDataRevCmp and finalizes the pending operation. + // A spec-compliant base station never returns dlDataRevCmp, so the pending + // row is removed here or it leaks. + if err := s.sendMessage(session, responseMsg); err != nil { + return err } - - // Clean up pending operation - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both cache and DB removal if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationFromDB, - "sessionID", session.DbSessionID, - "opId", msg.OpId, - "error", err) + s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationFromDatabase, + "error", err, "opId", msg.OpId) } - // Note: No manual fallback - trust StatusService single-writer pattern - - s.logger.InfoContext(s.sessionContext(session), LogBSSCIDLDataRevokeOperationCompleted, - "bsEui", session.BaseStationEUI, - "opId", msg.OpId) - return nil } @@ -1404,36 +1307,21 @@ func (s *Server) handleDLRXStatusQueryResponse(_ *Server, session *Session, msg "bsEui", session.BaseStationEUI, "opId", msg.OpId) - // Send dlRxStatQryCmp to complete the three-way handshake + // The service center completes its own SC-initiated dlRxStatQry operation + // (BSSCI §3.16): it sends dlRxStatQryCmp and finalizes the pending + // operation. A spec-compliant base station never returns dlRxStatQryCmp, + // so the pending row is removed here or it leaks. complete := map[string]interface{}{ "command": mioty.CmdDLRxStatusQueryComplete, "opId": msg.OpId, } - - return s.sendMessage(session, complete) -} - -// handleDLRXStatusQueryComplete handles dlRxStatQryCmp from base station -func (s *Server) handleDLRXStatusQueryComplete(_ *Server, session *Session, msg *Message, _ map[string]interface{}) error { - if session == nil { - return fmt.Errorf("%s", ResolveErrorMessage(errSessionNil)) + if err := s.sendMessage(session, complete); err != nil { + return err } - - s.logger.InfoContext(s.sessionContext(session), LogBSSCIDLRxStatusQueryOperationCompleted, - "bsEui", session.BaseStationEUI, - "opId", msg.OpId) - - // dlRxStat already marked 'received' by handleDLRXStatus. - // This Complete handler finalizes the three-way handshake only. - - // THEN remove from database - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both DB and memory cleanup if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationFromDB, - "error", err, - "opId", msg.OpId) + s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationFromDatabase, + "error", err, "opId", msg.OpId) } - return nil } diff --git a/KC-Core/pkg/bssci/downlink_handlers_actual_test.go b/KC-Core/pkg/bssci/downlink_handlers_actual_test.go index 623b19d..8bf9629 100644 --- a/KC-Core/pkg/bssci/downlink_handlers_actual_test.go +++ b/KC-Core/pkg/bssci/downlink_handlers_actual_test.go @@ -365,7 +365,9 @@ func TestHandleDLDataResultActual(t *testing.T) { // Create session session := &bssci.Session{ - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: bssci.TestBsEui01, + }, UserProvidedName: "Test BS", Conn: conn, } @@ -505,7 +507,9 @@ func TestThreeWayHandshake(t *testing.T) { sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, mockResolver) session := &bssci.Session{ - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: bssci.TestBsEui01, + }, UserProvidedName: "Test BS", Conn: conn, } diff --git a/KC-Core/pkg/bssci/downlink_handlers_integration_test.go b/KC-Core/pkg/bssci/downlink_handlers_integration_test.go index bbd6cdb..fdbf92b 100644 --- a/KC-Core/pkg/bssci/downlink_handlers_integration_test.go +++ b/KC-Core/pkg/bssci/downlink_handlers_integration_test.go @@ -70,7 +70,9 @@ func TestHandleDLDataResultIntegration(t *testing.T) { // Create test session session := &bssci.Session{ - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: bssci.TestBsEui01, + }, UserProvidedName: "Test BS", Conn: &mockConn{}, } @@ -251,7 +253,9 @@ func TestHandleDLRXStatusIntegration(t *testing.T) { sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) session := &bssci.Session{ - BaseStationEUI: bssci.TestBsEui01, + ProtocolSessionState: bssci.ProtocolSessionState{ + BaseStationEUI: bssci.TestBsEui01, + }, UserProvidedName: "Test BS", Conn: &mockConn{}, } diff --git a/KC-Core/pkg/bssci/downlink_handlers_test.go b/KC-Core/pkg/bssci/downlink_handlers_test.go index 4d6d53b..d8a1fb4 100644 --- a/KC-Core/pkg/bssci/downlink_handlers_test.go +++ b/KC-Core/pkg/bssci/downlink_handlers_test.go @@ -67,13 +67,15 @@ func TestSendDLDataRevoke_BuildsMessage_PersistsMetadata(t *testing.T) { // Create and register a test session session := &Session{ - ID: "test-session-revoke", - BaseStationEUI: TestBsEui04, - Conn: mockConn, - Encoding: EncodingMessagePack, - LastScOpId: -1, - HandshakeComplete: true, - Bidirectional: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-revoke", + BaseStationEUI: TestBsEui04, + Encoding: EncodingMessagePack, + LastScOpId: -1, + HandshakeComplete: true, + }, + Conn: mockConn, + Bidirectional: true, } server.RegisterSession(session) diff --git a/KC-Core/pkg/bssci/encoding_test.go b/KC-Core/pkg/bssci/encoding_test.go index bc61a6f..a206a29 100644 --- a/KC-Core/pkg/bssci/encoding_test.go +++ b/KC-Core/pkg/bssci/encoding_test.go @@ -313,14 +313,18 @@ func TestEncodingDetectionAndDecode(t *testing.T) { func TestEncodingPersistence(t *testing.T) { t.Run("SessionEncodingInitiallyEmpty", func(t *testing.T) { session := &Session{ - ID: "test-session", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + }, } assert.Equal(t, "", session.Encoding, "New session should have empty encoding") }) t.Run("SessionEncodingCanBeSet", func(t *testing.T) { session := &Session{ - ID: "test-session", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + }, } session.Encoding = "json" assert.Equal(t, "json", session.Encoding, "Session encoding should be settable") diff --git a/KC-Core/pkg/bssci/epstatus_forward_test.go b/KC-Core/pkg/bssci/epstatus_forward_test.go index 3b9446b..064e561 100644 --- a/KC-Core/pkg/bssci/epstatus_forward_test.go +++ b/KC-Core/pkg/bssci/epstatus_forward_test.go @@ -265,11 +265,13 @@ func TestHandleAttachComplete_BroadcastsEPStatus(t *testing.T) { // Setup session session := &Session{ - ID: "test-attach-epstatus", - BaseStationEUI: 0xABCDEF1234567890, - ResolvedTenantID: tenantID, - DbSessionID: 1, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-attach-epstatus", + BaseStationEUI: 0xABCDEF1234567890, + ResolvedTenantID: tenantID, + DbSessionID: 1, + Encoding: EncodingJSON, + }, } // Seed pending attach operation via StatusService with metadata @@ -344,11 +346,13 @@ func TestHandleDetachComplete_BroadcastsEPStatus(t *testing.T) { // Setup session session := &Session{ - ID: "test-detach-epstatus", - BaseStationEUI: 0x1234567890ABCDEF, - ResolvedTenantID: tenantID, - DbSessionID: 2, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-detach-epstatus", + BaseStationEUI: 0x1234567890ABCDEF, + ResolvedTenantID: tenantID, + DbSessionID: 2, + Encoding: EncodingJSON, + }, } // Seed pending detach operation via StatusService with metadata @@ -420,11 +424,13 @@ func TestHandleAttachComplete_NilBroadcaster_NoPanic(t *testing.T) { // NOTE: No SetSCACIEPStatusBroadcaster call - broadcaster is nil session := &Session{ - ID: "test-attach-nil-broadcaster", - BaseStationEUI: 0xFFFFFFFFFFFFFFFF, - ResolvedTenantID: tenantID, - DbSessionID: 3, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-attach-nil-broadcaster", + BaseStationEUI: 0xFFFFFFFFFFFFFFFF, + ResolvedTenantID: tenantID, + DbSessionID: 3, + Encoding: EncodingJSON, + }, } // Seed pending attach operation (use int64 for epEui to match handleAttachComplete) @@ -471,11 +477,13 @@ func TestHandleAttachComplete_BroadcastsEPStatus_FullTelemetry(t *testing.T) { // Setup session session := &Session{ - ID: "test-attach-full-telemetry", - BaseStationEUI: 0xABCDEF1234567890, - ResolvedTenantID: tenantID, - DbSessionID: 5, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-attach-full-telemetry", + BaseStationEUI: 0xABCDEF1234567890, + ResolvedTenantID: tenantID, + DbSessionID: 5, + Encoding: EncodingJSON, + }, } // Seed pending attach operation with ALL OTA fields per SCACI §3.13.1 @@ -575,11 +583,13 @@ func TestHandleDetachComplete_BroadcastsEPStatus_SignEqSnrFallback(t *testing.T) // Setup session session := &Session{ - ID: "test-detach-sign-eqsnr", - BaseStationEUI: 0x1234567890ABCDEF, - ResolvedTenantID: tenantID, - DbSessionID: 6, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-detach-sign-eqsnr", + BaseStationEUI: 0x1234567890ABCDEF, + ResolvedTenantID: tenantID, + DbSessionID: 6, + Encoding: EncodingJSON, + }, } // Seed pending detach operation with sign and eqSnr @@ -662,11 +672,13 @@ func TestHandleDetachComplete_NilBroadcaster_NoPanic(t *testing.T) { // NOTE: No SetSCACIEPStatusBroadcaster call - broadcaster is nil session := &Session{ - ID: "test-detach-nil-broadcaster", - BaseStationEUI: 0xFFFFFFFFFFFFFFFF, - ResolvedTenantID: tenantID, - DbSessionID: 4, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-detach-nil-broadcaster", + BaseStationEUI: 0xFFFFFFFFFFFFFFFF, + ResolvedTenantID: tenantID, + DbSessionID: 4, + Encoding: EncodingJSON, + }, } // Seed pending detach operation (use uint64 for epEui to match handleDetachComplete) diff --git a/KC-Core/pkg/bssci/errors_catalog.go b/KC-Core/pkg/bssci/errors_catalog.go index e3ccda9..d1870b6 100644 --- a/KC-Core/pkg/bssci/errors_catalog.go +++ b/KC-Core/pkg/bssci/errors_catalog.go @@ -12,6 +12,7 @@ const ( errBaseStationNotRegistered = "bssci.error.base_station_not_registered" errResumeCounterMismatch = "bssci.error.resume_counter_mismatch" errInvalidResumeCounters = "bssci.error.invalid_resume_counters" + errSessionResumeUnavailable = "bssci.error.session_resume_unavailable" // Protocol/framing errors errInvalidMessageFormat = "bssci.error.invalid_message_format" @@ -51,21 +52,22 @@ const ( errInvalidConnectCompleteOpId = "bssci.error.invalid_connect_complete_op_id" // Connect operation errors (BSSCI §3.3) - errMissingBsEui = "bssci.error.missing_bs_eui" - errInvalidBsEui = "bssci.error.invalid_bs_eui" - errInvalidUUIDByteType = "bssci.error.invalid_uuid_byte_type" - errInvalidUUIDLength = "bssci.error.invalid_uuid_length" - errUnsupportedUUIDType = "bssci.error.unsupported_uuid_type" - errUUIDDataNil = "bssci.error.uuid_data_nil" - errNoConnectedBaseStations = "bssci.error.no_connected_base_stations" - errFailedToLoadCA = "bssci.error.failed_to_load_ca" - errFailedToParseCA = "bssci.error.failed_to_parse_ca" - errFailedToLoadTLS = "bssci.error.failed_to_load_tls" - errFailedToStartTLS = "bssci.error.failed_to_start_tls" - errCommandBeforeHandshake = "bssci.error.command_before_handshake" - errInvalidHandshakeState = "bssci.error.invalid_handshake_state" - errMissingProtocolVersion = "bssci.error.missing_protocol_version" - errMissingBidiFlag = "bssci.error.missing_bidi_flag" + errMissingBsEui = "bssci.error.missing_bs_eui" + errInvalidBsEui = "bssci.error.invalid_bs_eui" + errInvalidUUIDByteType = "bssci.error.invalid_uuid_byte_type" + errInvalidUUIDLength = "bssci.error.invalid_uuid_length" + errUnsupportedUUIDType = "bssci.error.unsupported_uuid_type" + errUUIDDataNil = "bssci.error.uuid_data_nil" + errNoConnectedBaseStations = "bssci.error.no_connected_base_stations" + errFailedToLoadCA = "bssci.error.failed_to_load_ca" + errFailedToParseCA = "bssci.error.failed_to_parse_ca" + errFailedToLoadTLS = "bssci.error.failed_to_load_tls" + errFailedToStartTLS = "bssci.error.failed_to_start_tls" + errCommandBeforeHandshake = "bssci.error.command_before_handshake" + errInboundServiceCenterCommand = "bssci.error.inbound_service_center_command" + errInvalidHandshakeState = "bssci.error.invalid_handshake_state" + errMissingProtocolVersion = "bssci.error.missing_protocol_version" + errMissingBidiFlag = "bssci.error.missing_bidi_flag" // Attach operation errors (BSSCI §3.6) errMissingEpEui = "bssci.error.missing_ep_eui" @@ -200,10 +202,11 @@ const ( errDetachPropagateRejected = "bssci.error.detach_propagate_rejected" // Status operation errors - errFailedToSendStatusRequest = "bssci.error.failed_to_send_status_request" - errMissingStatusCode = "bssci.error.missing_status_code" - errMissingStatusMessage = "bssci.error.missing_status_message" - errMissingStatusTime = "bssci.error.missing_status_time" + errFailedToSendStatusRequest = "bssci.error.failed_to_send_status_request" + errFailedToPersistPendingStatusOperation = "bssci.error.failed_to_persist_pending_status_operation" + errMissingStatusCode = "bssci.error.missing_status_code" + errMissingStatusMessage = "bssci.error.missing_status_message" + errMissingStatusTime = "bssci.error.missing_status_time" // Management HTTP API errors (BSSCI §3.6 attach/§3.7 detach propagation) // Exported for use by pkg/management HTTP handlers @@ -334,6 +337,12 @@ var errorDefinitions = map[string]ErrorDefinition{ SpecSection: "§3.3", Severity: SeverityError, }, + errSessionResumeUnavailable: { + Token: "bssci.error.session_resume_unavailable", + Message: "Session resume temporarily unavailable; retry", + SpecSection: "§5.3", + Severity: SeverityError, + }, errResumeCounterMismatch: { Token: "bssci.error.resume_counter_mismatch", Message: "Resume rejected: operation counters do not match persisted session state", @@ -590,6 +599,12 @@ var errorDefinitions = map[string]ErrorDefinition{ SpecSection: "§3.3", Severity: "protocol_violation", }, + errInboundServiceCenterCommand: { + Token: "bssci.error.inbound_service_center_command", + Message: "Inbound service-center-initiated command is not permitted", + SpecSection: "§5.5", + Severity: SeverityError, + }, errInvalidHandshakeState: { Token: "bssci.error.invalid_handshake_state", Message: "Connect operation message not valid in current handshake state", @@ -1258,6 +1273,12 @@ var errorDefinitions = map[string]ErrorDefinition{ SpecSection: "§3.5", Severity: SeverityError, }, + errFailedToPersistPendingStatusOperation: { + Token: "bssci.error.failed_to_persist_pending_status_operation", + Message: "Failed to persist pending status operation", + SpecSection: "§3.5", + Severity: SeverityError, + }, errMissingStatusCode: { Token: "bssci.error.missing_status_code", Message: "Missing mandatory field: code", diff --git a/KC-Core/pkg/bssci/log_messages.go b/KC-Core/pkg/bssci/log_messages.go index 09dd867..c325d67 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -53,6 +53,8 @@ const ( LogBSSCIHandshakeComplete = "handshake complete" // LogBSSCIRejectingCommandBeforeHandshake logs when a command is rejected due to incomplete handshake LogBSSCIRejectingCommandBeforeHandshake = "Rejecting command before handshake complete" + // LogBSSCIRejectingInboundServiceCenterCommand logs when a base station sends a service-center-initiated command + LogBSSCIRejectingInboundServiceCenterCommand = "Rejecting inbound service-center-initiated command from base station" // LogBSSCIConnectHandshakeNotComplete logs when connect handshake is incomplete LogBSSCIConnectHandshakeNotComplete = "Connect handshake not complete for session" // LogBSSCIConnectHandshakeNotCompleteDL logs when connect handshake is incomplete for downlink @@ -296,8 +298,6 @@ const ( // LogBSSCIReceivedDLDataQueRspFromBaseStation is a log message constant LogBSSCIReceivedDLDataQueRspFromBaseStation = "Received dlDataQueRsp from base station" - // LogBSSCIDLDataQueueOperationCompleted is a log message constant - LogBSSCIDLDataQueueOperationCompleted = "DL data queue operation completed" // LogBSSCIReceivedDLRxStatFromBaseStation is a log message constant LogBSSCIReceivedDLRxStatFromBaseStation = "Received dlRxStat from base station" // LogBSSCIReceivedDLRxStatRspFromBaseStation is a log message constant @@ -306,16 +306,12 @@ const ( LogBSSCIReceivedDLRxStatQryRspFromBaseStation = "Received dlRxStatQryRsp from base station" // LogBSSCIDLRxStatusOperationCompleted is a log message constant LogBSSCIDLRxStatusOperationCompleted = "DL RX status operation completed" - // LogBSSCIDLRxStatusQueryOperationCompleted is a log message constant - LogBSSCIDLRxStatusQueryOperationCompleted = "DL RX status query operation completed" // LogBSSCISentDLRxStatQryToBaseStation is a log message constant LogBSSCISentDLRxStatQryToBaseStation = "Sent dlRxStatQry to base station" // LogBSSCIPersistedDLRxStatus is a log message constant LogBSSCIPersistedDLRxStatus = "Persisted DL RX status" // LogBSSCIReceivedDLDataRevRspFromBaseStation is a log message constant LogBSSCIReceivedDLDataRevRspFromBaseStation = "Received dlDataRevRsp from base station" - // LogBSSCIDLDataRevokeOperationCompleted is a log message constant - LogBSSCIDLDataRevokeOperationCompleted = "DL data revoke operation completed" // LogBSSCISentDLDataRevToBaseStation is a log message constant LogBSSCISentDLDataRevToBaseStation = "Sent dlDataRev to base station" // LogBSSCIInvalidQueueIDInRevokeResponse is a log message constant @@ -485,8 +481,6 @@ const ( LogBSSCISendingStatusRequestToBaseStation = "Sending status request to base station" // LogBSSCIReceivedStatusRspFromBaseStation is a log message constant LogBSSCIReceivedStatusRspFromBaseStation = "Received statusRsp from base station" - // LogBSSCIStatusOperationCompleted is a log message constant - LogBSSCIStatusOperationCompleted = "Status operation completed" // LogBSSCIStatusMechanismAlreadyRunningForSession is a log message constant LogBSSCIStatusMechanismAlreadyRunningForSession = "Status mechanism already running for session" // LogBSSCIStoppingStatusMechanism is a log message constant @@ -501,8 +495,6 @@ const ( LogBSSCIFailedToGetBaseStationByEUI = "Failed to get base station by EUI" // LogBSSCIFailedToPersistPendingStatusOperation is a log message constant LogBSSCIFailedToPersistPendingStatusOperation = "Failed to persist pending status operation" - // LogBSSCIFailedToRemovePendingStatusOperation is a log message constant - LogBSSCIFailedToRemovePendingStatusOperation = "Failed to remove pending status operation" // LogBSSCIFailedToUpdateBaseStationStatus is a log message constant LogBSSCIFailedToUpdateBaseStationStatus = "Failed to update base station status" // LogBSSCIFailedToPersistStatusHistory is a log message constant @@ -645,8 +637,6 @@ const ( // LogBSSCIDeduplicationError is a log message constant LogBSSCIDeduplicationError = "Deduplication error" - // LogBSSCICompletingThreeWayHandshakeForError is a log message constant - LogBSSCICompletingThreeWayHandshakeForError = "Completing three-way handshake for error operation" // LogBSSCIErrorOperationHandshakeCompletedDatabaseNotUpdated is a log message constant LogBSSCIErrorOperationHandshakeCompletedDatabaseNotUpdated = "Error operation handshake completed, database state NOT updated" // LogBSSCIBaseStationReportedError is a log message constant @@ -681,8 +671,6 @@ const ( LogBSSCIErrorAckSent = "Sent errorAck to base station for failed operation" // LogBSSCIAcknowledgingErrorForUnknownOperation is a log message constant per BSSCI §5.17 LogBSSCIAcknowledgingErrorForUnknownOperation = "Acknowledging error for unknown operation per BSSCI §5.17" - // LogBSSCIFailedToSendCompletionMessageForErrorOperation is a log message constant - LogBSSCIFailedToSendCompletionMessageForErrorOperation = "Failed to send completion message for error operation" // LogBSSCIFailedToStoreMessage is a log message constant LogBSSCIFailedToStoreMessage = "Failed to store message" // LogBSSCIFailedToStoreULDataCompletionEvent is a log message constant diff --git a/KC-Core/pkg/bssci/mandatory_field_validation_test.go b/KC-Core/pkg/bssci/mandatory_field_validation_test.go index bad8e6a..5df63a4 100644 --- a/KC-Core/pkg/bssci/mandatory_field_validation_test.go +++ b/KC-Core/pkg/bssci/mandatory_field_validation_test.go @@ -386,11 +386,13 @@ func TestStatusMandatoryFields(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } msg := &Message{ @@ -463,9 +465,11 @@ func TestConnectMandatoryFields(t *testing.T) { }) session := &Session{ - ID: "test-session", - Conn: mockConn, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + Encoding: "json", + }, + Conn: mockConn, } msg := &Message{ @@ -509,12 +513,14 @@ func TestRollbackOnSendFailure(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, - LastScOpId: -5, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + LastScOpId: -5, + }, + Conn: mockConn, } server.RegisterSession(session) @@ -676,11 +682,13 @@ func TestAttachMandatoryFields(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } msg := &Message{ @@ -765,10 +773,12 @@ func TestULDataMandatoryFields(t *testing.T) { server.SetDeduplicator(NewMessageDeduplicator(5 * time.Minute)) session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui04, - Conn: mockConn, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui04, + Encoding: "json", + }, + Conn: mockConn, } msg := &Message{ @@ -853,11 +863,13 @@ func TestULDataMandatoryFields(t *testing.T) { server.SetDeduplicator(NewMessageDeduplicator(5 * time.Minute)) session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } msg := &Message{ @@ -991,11 +1003,13 @@ func TestDLDataResultMandatoryFields(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } msg := &Message{ @@ -1158,13 +1172,15 @@ func TestDetachMandatoryFields(t *testing.T) { server.RegisterHandlers() session := &Session{ - ID: "test-detach-validation", - BaseStationEUI: TestBsEui01, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - BsOpId: 0, - ScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-detach-validation", + BaseStationEUI: TestBsEui01, + Encoding: "msgpack", + HandshakeComplete: true, + BsOpId: 0, + ScOpId: 0, + }, + Conn: mockConn, } msg := &Message{ @@ -1252,13 +1268,16 @@ func TestAttachNonceValidation(t *testing.T) { server.RegisterHandlers() session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, // Must match endpoint.TenantID - DbSessionID: 1, // Required for pending operation persistence - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + Encoding: "json", + HandshakeComplete: true, + // Required for pending operation persistence + DbSessionID: 1, + }, + Conn: mockConn, } data := buildValidAttachData("nonce", tt.nonce) @@ -1342,13 +1361,15 @@ func TestAttachSignValidation(t *testing.T) { server.RegisterHandlers() session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } data := buildValidAttachData("sign", tt.sign) @@ -1437,13 +1458,15 @@ func TestAttachCounterRange(t *testing.T) { server.RegisterHandlers() session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui01, - ResolvedTenantID: 1, - DbSessionID: 1, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } var data map[string]interface{} diff --git a/KC-Core/pkg/bssci/message_assembly_test.go b/KC-Core/pkg/bssci/message_assembly_test.go index 65d85c6..ddd5d20 100644 --- a/KC-Core/pkg/bssci/message_assembly_test.go +++ b/KC-Core/pkg/bssci/message_assembly_test.go @@ -111,12 +111,14 @@ func TestSCOriginatedOperations(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: encoding, - LastScOpId: -1, - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui04, + Encoding: encoding, + LastScOpId: -1, + HandshakeComplete: true, + }, + Conn: mockConn, } // Register session with server for operations that look up by sessionID @@ -254,11 +256,13 @@ func TestBSOriginatedResponseHandlers(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: encoding, - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui04, + Encoding: encoding, + HandshakeComplete: true, + }, + Conn: mockConn, } // Setup message data @@ -340,13 +344,15 @@ func newAssemblySession(t *testing.T, encoding, sessionID string) (*bssci.Server queueSerializer, auditLogger, tenantResolver) session := &bssci.Session{ - ID: sessionID, - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: encoding, - LastScOpId: -1, - HandshakeComplete: true, - Bidirectional: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: sessionID, + BaseStationEUI: bssci.TestBsEui04, + Encoding: encoding, + LastScOpId: -1, + HandshakeComplete: true, + }, + Conn: mockConn, + Bidirectional: true, } server.RegisterSession(session) diff --git a/KC-Core/pkg/bssci/message_metadata.go b/KC-Core/pkg/bssci/message_metadata.go index d87896a..b3b9186 100644 --- a/KC-Core/pkg/bssci/message_metadata.go +++ b/KC-Core/pkg/bssci/message_metadata.go @@ -693,26 +693,9 @@ var messageRegistry = map[string]*MessageSpec{ SpecRef: "§3.1.1 - Maximum known SC opId to resume", }, }, - ConditionalRules: []ConditionalRule{ - { - // Rule 1: If snBsOpId is present, snScOpId must also be present - Condition: func(data map[string]interface{}) bool { - return data["snBsOpId"] != nil - }, - Required: []string{"snScOpId"}, - Forbidden: []string{}, - ErrorMsg: "snScOpId required when snBsOpId present (session resume mutual presence per MIOTY radio spec §3.6.5.3)", - }, - { - // Rule 2: If snScOpId is present, snBsOpId must also be present - Condition: func(data map[string]interface{}) bool { - return data["snScOpId"] != nil - }, - Required: []string{"snBsOpId"}, - Forbidden: []string{}, - ErrorMsg: "snBsOpId required when snScOpId present (session resume mutual presence per MIOTY radio spec §3.6.5.3)", - }, - }, + // snBsOpId and snScOpId are independently optional (BSSCI rev1 + // §5.3.1): each asserts its own resume constraint; absence means the + // constraint is not asserted. No mutual-presence coupling. }, mioty.CmdConnectResponse: { diff --git a/KC-Core/pkg/bssci/message_metadata_directions_gen.go b/KC-Core/pkg/bssci/message_metadata_directions_gen.go index 8293f43..1bfc1cc 100644 --- a/KC-Core/pkg/bssci/message_metadata_directions_gen.go +++ b/KC-Core/pkg/bssci/message_metadata_directions_gen.go @@ -1,5 +1,4 @@ // Code generated by tools/extract_command_directions.go. DO NOT EDIT. -// Generated: 2026-05-11 20:19:09 package bssci @@ -15,13 +14,13 @@ var CommandDirectionMap = map[string]CommandDirection{ // Ping operations. mioty.CmdPing: DirectionBidirectional, - mioty.CmdPingResponse: DirectionBStoSC, - mioty.CmdPingComplete: DirectionBStoSC, + mioty.CmdPingResponse: DirectionBidirectional, + mioty.CmdPingComplete: DirectionBidirectional, // Status operations. - mioty.CmdStatus: DirectionBidirectional, + mioty.CmdStatus: DirectionSCtoBS, mioty.CmdStatusResponse: DirectionBStoSC, - mioty.CmdStatusComplete: DirectionBStoSC, + mioty.CmdStatusComplete: DirectionSCtoBS, // Attach operations. mioty.CmdAttach: DirectionBStoSC, @@ -36,12 +35,12 @@ var CommandDirectionMap = map[string]CommandDirection{ // Attach propagate operations. mioty.CmdAttachPropagate: DirectionSCtoBS, mioty.CmdAttachPropagateResponse: DirectionBStoSC, - mioty.CmdAttachPropagateComplete: DirectionBStoSC, + mioty.CmdAttachPropagateComplete: DirectionSCtoBS, // Detach propagate operations. mioty.CmdDetachPropagate: DirectionSCtoBS, mioty.CmdDetachPropagateResponse: DirectionBStoSC, - mioty.CmdDetachPropagateComplete: DirectionBStoSC, + mioty.CmdDetachPropagateComplete: DirectionSCtoBS, // UL data operations. mioty.CmdULData: DirectionBStoSC, @@ -51,17 +50,17 @@ var CommandDirectionMap = map[string]CommandDirection{ // UL data transmit operations. mioty.CmdULDataTransmit: DirectionSCtoBS, mioty.CmdULDataTransmitResponse: DirectionBStoSC, - mioty.CmdULDataTransmitComplete: DirectionBStoSC, + mioty.CmdULDataTransmitComplete: DirectionSCtoBS, // DL data queue operations. mioty.CmdDLDataQueue: DirectionSCtoBS, mioty.CmdDLDataQueueResponse: DirectionBStoSC, - mioty.CmdDLDataQueueComplete: DirectionBStoSC, + mioty.CmdDLDataQueueComplete: DirectionSCtoBS, // DL data revoke operations. mioty.CmdDLDataRevoke: DirectionSCtoBS, mioty.CmdDLDataRevokeResponse: DirectionBStoSC, - mioty.CmdDLDataRevokeComplete: DirectionBStoSC, + mioty.CmdDLDataRevokeComplete: DirectionSCtoBS, // DL data result operations (BSSCI §3.14). Note: SCACI §3.12 uses different response/complete names. mioty.CmdDLDataResult: DirectionBStoSC, @@ -78,7 +77,7 @@ var CommandDirectionMap = map[string]CommandDirection{ // DL RX status query operations. mioty.CmdDLRxStatusQuery: DirectionSCtoBS, mioty.CmdDLRxStatusQueryResponse: DirectionBStoSC, - mioty.CmdDLRxStatusQueryComplete: DirectionBStoSC, + mioty.CmdDLRxStatusQueryComplete: DirectionSCtoBS, // Error operations. mioty.CmdError: DirectionBidirectional, diff --git a/KC-Core/pkg/bssci/message_normalization_gate_test.go b/KC-Core/pkg/bssci/message_normalization_gate_test.go index 9767687..0856661 100644 --- a/KC-Core/pkg/bssci/message_normalization_gate_test.go +++ b/KC-Core/pkg/bssci/message_normalization_gate_test.go @@ -36,10 +36,13 @@ func TestNormalizationOnlyForBStoSCCommands(t *testing.T) { {mioty.CmdDetachPropagate, false, "SC→BS", "Detach propagate (SC initiates)"}, {mioty.CmdDLDataQueue, false, "SC→BS", "DL data queue (SC initiates)"}, {mioty.CmdAttachResponse, false, "SC→BS", "Attach response to BS"}, + {mioty.CmdStatus, false, "SC→BS", "Status (SC initiates, BSSCI 5.5)"}, + {mioty.CmdStatusComplete, false, "SC→BS", "Status completion (SC sends)"}, + {mioty.CmdDLDataQueueComplete, false, "SC→BS", "DL queue completion (SC sends)"}, // Bidirectional: Can be initiated by either party (normalize when received) {mioty.CmdPing, true, "Bidirectional", "Ping (can be initiated by either party)"}, - {mioty.CmdStatus, true, "Bidirectional", "Status (can be initiated by either party)"}, + {mioty.CmdPingResponse, true, "Bidirectional", "Ping response (either party)"}, {mioty.CmdError, true, "Bidirectional", "Error (can be initiated by either party)"}, } @@ -65,11 +68,13 @@ func TestNormalizationOnlyForBStoSCCommands(t *testing.T) { // Create minimal session with mock connection session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", - HandshakeComplete: true, // Allow non-handshake commands - Conn: &testutil.TestConn{}, // Mock connection to prevent nil pointer crash + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: &testutil.TestConn{}, } // Build valid minimal payload for this command @@ -176,11 +181,13 @@ func TestNormalizationSkipsUnknownCommands(t *testing.T) { // Create minimal session session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", - HandshakeComplete: true, - Conn: &testutil.TestConn{}, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: &testutil.TestConn{}, } // Test with unknown command diff --git a/KC-Core/pkg/bssci/mqtt_publish_test.go b/KC-Core/pkg/bssci/mqtt_publish_test.go index e1f5467..aa5fab6 100644 --- a/KC-Core/pkg/bssci/mqtt_publish_test.go +++ b/KC-Core/pkg/bssci/mqtt_publish_test.go @@ -238,11 +238,13 @@ func TestHandleAttachComplete_NilMQTTPublisher_NoPanic(t *testing.T) { // mqttPublisher is nil (default) session := &Session{ - ID: "test-nil-mqtt-attach", - BaseStationEUI: 0xABCD, - ResolvedTenantID: 42, - DbSessionID: 1, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-nil-mqtt-attach", + BaseStationEUI: 0xABCD, + ResolvedTenantID: 42, + DbSessionID: 1, + Encoding: EncodingJSON, + }, } pendingOp := &PendingOperation{ @@ -268,11 +270,13 @@ func TestHandleDetachComplete_NilMQTTPublisher_NoPanic(t *testing.T) { server := NewTestServerWithMemoryStatusService(testLogger, nil, nil, 42) session := &Session{ - ID: "test-nil-mqtt-detach", - BaseStationEUI: 0xABCD, - ResolvedTenantID: 42, - DbSessionID: 1, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-nil-mqtt-detach", + BaseStationEUI: 0xABCD, + ResolvedTenantID: 42, + DbSessionID: 1, + Encoding: EncodingJSON, + }, } pendingOp := &PendingOperation{ @@ -307,11 +311,13 @@ func TestHandleAttachComplete_PublishesMQTTWithOwnerOrg(t *testing.T) { } session := &Session{ - ID: "test-attach-mqtt", - BaseStationEUI: 0xABCDEF1234567890, - ResolvedTenantID: 42, - DbSessionID: 1, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-attach-mqtt", + BaseStationEUI: 0xABCDEF1234567890, + ResolvedTenantID: 42, + DbSessionID: 1, + Encoding: EncodingJSON, + }, } epEUI := int64(0x70B3D59CD00009E6) @@ -357,11 +363,13 @@ func TestHandleAttachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { } session := &Session{ - ID: "test-attach-no-org", - BaseStationEUI: 0xABCD, - ResolvedTenantID: 42, - DbSessionID: 1, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-attach-no-org", + BaseStationEUI: 0xABCD, + ResolvedTenantID: 42, + DbSessionID: 1, + Encoding: EncodingJSON, + }, } pendingOp := &PendingOperation{ @@ -395,11 +403,13 @@ func TestHandleDetachComplete_PublishesMQTTWithTypedMetaOrg(t *testing.T) { server.SetMQTTPublisher(syncMock) session := &Session{ - ID: "test-detach-mqtt", - BaseStationEUI: 0xABCDEF1234567890, - ResolvedTenantID: 42, - DbSessionID: 1, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-detach-mqtt", + BaseStationEUI: 0xABCDEF1234567890, + ResolvedTenantID: 42, + DbSessionID: 1, + Encoding: EncodingJSON, + }, } epEUI := uint64(0x70B3D59CD00009E6) @@ -440,11 +450,13 @@ func TestHandleDetachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { server.SetMQTTPublisher(mock) session := &Session{ - ID: "test-detach-no-org", - BaseStationEUI: 0xABCD, - ResolvedTenantID: 42, - DbSessionID: 1, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-detach-no-org", + BaseStationEUI: 0xABCD, + ResolvedTenantID: 42, + DbSessionID: 1, + Encoding: EncodingJSON, + }, } pendingOp := &PendingOperation{ @@ -514,12 +526,14 @@ func TestHandleULData_PublishesMQTTUplink(t *testing.T) { // Create session with TestConn to absorb sendMessage writes session := &Session{ - ID: "test-uldata-mqtt", - BaseStationEUI: 0xABCDEF1234567890, - ResolvedTenantID: tenantID, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: &testutil.TestConn{Encoding: "json"}, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-uldata-mqtt", + BaseStationEUI: 0xABCDEF1234567890, + ResolvedTenantID: tenantID, + DbSessionID: 1, + Encoding: EncodingJSON, + }, + Conn: &testutil.TestConn{Encoding: "json"}, } // Build UL data map with required fields @@ -577,12 +591,14 @@ func TestHandleULData_OrgUnresolved_SkipsPublish(t *testing.T) { server.SetMQTTPublisher(mock) session := &Session{ - ID: "test-uldata-no-org", - BaseStationEUI: 0xABCD, - ResolvedTenantID: tenantID, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: &testutil.TestConn{Encoding: "json"}, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-uldata-no-org", + BaseStationEUI: 0xABCD, + ResolvedTenantID: tenantID, + DbSessionID: 1, + Encoding: EncodingJSON, + }, + Conn: &testutil.TestConn{Encoding: "json"}, } data := map[string]interface{}{ @@ -629,12 +645,14 @@ func TestHandleDLDataResult_PublishesMQTTDLResult(t *testing.T) { // Session with TestConn for sendMessage session := &Session{ - ID: "test-dlresult-mqtt", - BaseStationEUI: 0xABCDEF1234567890, - ResolvedTenantID: tenantID, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: &testutil.TestConn{Encoding: "json"}, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-dlresult-mqtt", + BaseStationEUI: 0xABCDEF1234567890, + ResolvedTenantID: tenantID, + DbSessionID: 1, + Encoding: EncodingJSON, + }, + Conn: &testutil.TestConn{Encoding: "json"}, } // Build DL result data map (handleDLDataResult uses type assertions directly) @@ -681,12 +699,14 @@ func TestHandleDLDataResult_OrgUnresolved_SkipsPublish(t *testing.T) { server.SetMQTTPublisher(mock) session := &Session{ - ID: "test-dlresult-no-org", - BaseStationEUI: 0xABCD, - ResolvedTenantID: tenantID, - DbSessionID: 1, - Encoding: EncodingJSON, - Conn: &testutil.TestConn{Encoding: "json"}, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-dlresult-no-org", + BaseStationEUI: 0xABCD, + ResolvedTenantID: tenantID, + DbSessionID: 1, + Encoding: EncodingJSON, + }, + Conn: &testutil.TestConn{Encoding: "json"}, } data := map[string]interface{}{ diff --git a/KC-Core/pkg/bssci/operation_id_validation_test.go b/KC-Core/pkg/bssci/operation_id_validation_test.go index e34957a..dcf87e7 100644 --- a/KC-Core/pkg/bssci/operation_id_validation_test.go +++ b/KC-Core/pkg/bssci/operation_id_validation_test.go @@ -51,8 +51,11 @@ func TestSCOperationIDMustBeNegative(t *testing.T) { // Create test session session := &Session{ - BaseStationEUI: 123456789, // Non-zero to pass connect check - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + // Non-zero to pass connect check + BaseStationEUI: 123456789, + LastScOpId: 0, + }, } // Call validateOperationID with isBaseStationInitiated = false (SC operation) @@ -122,8 +125,10 @@ func TestSCOperationIDStrictDecrement(t *testing.T) { // Create test session with initial LastScOpId session := &Session{ - BaseStationEUI: 123456789, - LastScOpId: tt.lastScOpId, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: 123456789, + LastScOpId: tt.lastScOpId, + }, } // Call validateOperationID @@ -191,8 +196,10 @@ func TestBSOperationIDMustBePositive(t *testing.T) { // Create session with BaseStationEUI set (after connect) session := &Session{ - BaseStationEUI: 123456789, - LastBsOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: 123456789, + LastBsOpId: 0, + }, } // Call validateOperationID with isBaseStationInitiated = true @@ -267,8 +274,10 @@ func TestBSOperationIDStrictIncrement(t *testing.T) { server := NewTestServerWithMemoryStatusService(log, nil, nil, 1) session := &Session{ - BaseStationEUI: 123456789, - LastBsOpId: tt.lastBsOpId, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: 123456789, + LastBsOpId: tt.lastBsOpId, + }, } // Call validateOperationID with isBaseStationInitiated = true diff --git a/KC-Core/pkg/bssci/outbound_validation_test.go b/KC-Core/pkg/bssci/outbound_validation_test.go index fd2b0ca..353f21c 100644 --- a/KC-Core/pkg/bssci/outbound_validation_test.go +++ b/KC-Core/pkg/bssci/outbound_validation_test.go @@ -131,9 +131,11 @@ func TestValidateAllCatalogCommands(t *testing.T) { server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + }, } for _, command := range commands { @@ -158,9 +160,11 @@ func TestValidationMissingCommand(t *testing.T) { server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + }, } // Message without command field @@ -191,9 +195,11 @@ func TestValidationMissingOpId(t *testing.T) { server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + }, } // Message without opId field - should fail validation (MIOTY requires opId on all frames) @@ -275,9 +281,11 @@ func TestValidationExtraField(t *testing.T) { server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + }, } // Valid payload with extra field @@ -309,9 +317,11 @@ func TestValidationUnknownCommand(t *testing.T) { server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + }, } // Message with unknown command @@ -378,9 +388,11 @@ func TestValidationStructMessages(t *testing.T) { server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + DbSessionID: 1, + Encoding: "json", + }, } t.Run("ConnectResponse_struct", func(t *testing.T) { diff --git a/KC-Core/pkg/bssci/pending_op_leak_test.go b/KC-Core/pkg/bssci/pending_op_leak_test.go new file mode 100644 index 0000000..7497a14 --- /dev/null +++ b/KC-Core/pkg/bssci/pending_op_leak_test.go @@ -0,0 +1,96 @@ +package bssci + +import ( + "testing" + "time" + + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSCInitiatedOperationsFinalizeAfterCmp verifies that the service center's +// own SC-initiated operations (status, dlDataQue, dlDataRev, dlRxStatQry) remove +// their pending operation after the SC sends its completion. A spec-compliant +// base station never returns those completions, so without finalization here the +// pending rows leak (the historical 14k-row status leak). +func TestSCInitiatedOperationsFinalizeAfterCmp(t *testing.T) { + newServer := func(t *testing.T) (*Server, StatusService, *Session) { + t.Helper() + log := newRecordingLogger() + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, storage := CreateTestServices(log, nil) + server := NewTestServer(log, storage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) + server.config = &Config{MessageEncoding: EncodingJSON} + server.storage = storage + server.RegisterHandlers() + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "leak-test", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + HandshakeComplete: true, + }, + Conn: &bsscitest.TestConn{Encoding: EncodingJSON}, + } + return server, statusSvc, session + } + + cases := []struct { + name string + opType string + respCmd string + invoke func(s *Server, sess *Session, msg *Message, data map[string]interface{}) error + }{ + { + name: "status", + opType: mioty.CmdStatus, + respCmd: mioty.CmdStatusResponse, + invoke: func(s *Server, sess *Session, msg *Message, data map[string]interface{}) error { + return s.handleStatusResponse(s, sess, msg, data) + }, + }, + { + name: "dlRxStatQry", + opType: mioty.CmdDLRxStatusQuery, + respCmd: mioty.CmdDLRxStatusQueryResponse, + invoke: func(s *Server, sess *Session, msg *Message, data map[string]interface{}) error { + return s.handleDLRXStatusQueryResponse(s, sess, msg, data) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server, statusSvc, session := newServer(t) + const opID = int64(-7) + + pendingOp := &PendingOperation{ + OperationType: tc.opType, + CreatedAt: time.Now(), + Metadata: map[string]interface{}{}, + } + require.NoError(t, statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, session.DbSessionID)) + _, err := statusSvc.GetPendingOperation(session, opID) + require.NoError(t, err, "pending op must exist before the response") + + data := map[string]interface{}{ + "command": tc.respCmd, + "opId": opID, + // status response mandatory fields (ignored by dlRxStatQry) + "code": int64(0), + "message": "ok", + "time": time.Now().UnixNano(), + } + msg := &Message{Command: tc.respCmd, OpId: opID, Data: data} + require.NoError(t, tc.invoke(server, session, msg, data)) + + _, err = statusSvc.GetPendingOperation(session, opID) + assert.Error(t, err, "pending op must be removed after the SC sends its completion") + }) + } +} diff --git a/KC-Core/pkg/bssci/pending_ops_collision_test.go b/KC-Core/pkg/bssci/pending_ops_collision_test.go index 0c3190b..90dd0d2 100644 --- a/KC-Core/pkg/bssci/pending_ops_collision_test.go +++ b/KC-Core/pkg/bssci/pending_ops_collision_test.go @@ -25,15 +25,19 @@ func TestPendingOpsCompositeKey(t *testing.T) { // Create two sessions with different IDs session1 := &Session{ - ID: session1ID, - DbSessionID: 1, - BaseStationEUI: TestBsEuiMulti01, + ProtocolSessionState: ProtocolSessionState{ + ID: session1ID, + DbSessionID: 1, + BaseStationEUI: TestBsEuiMulti01, + }, } session2 := &Session{ - ID: session2ID, - DbSessionID: 2, - BaseStationEUI: TestBsEuiMulti02, + ProtocolSessionState: ProtocolSessionState{ + ID: session2ID, + DbSessionID: 2, + BaseStationEUI: TestBsEuiMulti02, + }, } // Create server with StatusService @@ -99,9 +103,9 @@ func TestPendingOpsSessionIsolation(t *testing.T) { // Create three sessions simulating three base stations sessions := []*Session{ - {ID: "bs-alpha", DbSessionID: 1, BaseStationEUI: 0xAAAAAAAAAAAAAAAA}, - {ID: "bs-beta", DbSessionID: 2, BaseStationEUI: 0xBBBBBBBBBBBBBBBB}, - {ID: "bs-gamma", DbSessionID: 3, BaseStationEUI: 0xCCCCCCCCCCCCCCCC}, + {ProtocolSessionState: ProtocolSessionState{ID: "bs-alpha", DbSessionID: 1, BaseStationEUI: 0xAAAAAAAAAAAAAAAA}}, + {ProtocolSessionState: ProtocolSessionState{ID: "bs-beta", DbSessionID: 2, BaseStationEUI: 0xBBBBBBBBBBBBBBBB}}, + {ProtocolSessionState: ProtocolSessionState{ID: "bs-gamma", DbSessionID: 3, BaseStationEUI: 0xCCCCCCCCCCCCCCCC}}, } // Create server with StatusService @@ -174,8 +178,10 @@ func TestMakeSessionOpKey(t *testing.T) { t.Parallel() session := &Session{ - ID: "test-session-123", - DbSessionID: 42, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-123", + DbSessionID: 42, + }, } opID := int64(999) @@ -190,8 +196,10 @@ func TestMakeSessionOpKey(t *testing.T) { // Test 2: Different sessions produce different keys session2 := &Session{ - ID: "test-session-456", - DbSessionID: 43, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-456", + DbSessionID: 43, + }, } key3 := makeSessionOpKey(session2, opID) @@ -240,7 +248,12 @@ func TestPendingOperationSessionSlug(t *testing.T) { sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) - session := &Session{ID: sessionID, DbSessionID: 1} + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: sessionID, + DbSessionID: 1, + }, + } ctx := context.Background() err := server.statusSvc.RecordPendingOperation(ctx, session, opID, op, session.DbSessionID) require.NoError(t, err, "Should record pending operation") @@ -269,8 +282,8 @@ func TestPendingOpsRaceCondition(t *testing.T) { // `go test -race` which is part of the plan's "Run regression suite with race detector" step. sessions := []*Session{ - {ID: "concurrent-1", DbSessionID: 1}, - {ID: "concurrent-2", DbSessionID: 2}, + {ProtocolSessionState: ProtocolSessionState{ID: "concurrent-1", DbSessionID: 1}}, + {ProtocolSessionState: ProtocolSessionState{ID: "concurrent-2", DbSessionID: 2}}, } ctx := context.Background() diff --git a/KC-Core/pkg/bssci/phase_c2_regression_test.go b/KC-Core/pkg/bssci/phase_c2_regression_test.go index e079f75..abccbd5 100644 --- a/KC-Core/pkg/bssci/phase_c2_regression_test.go +++ b/KC-Core/pkg/bssci/phase_c2_regression_test.go @@ -325,8 +325,10 @@ func Test_SendAttachPropagateBySessionID_SessionValidation(t *testing.T) { t.Run("ExistingSession", func(t *testing.T) { // Add a session session := &Session{ - ID: "valid-session", - BaseStationEUI: TestBsEui02, + ProtocolSessionState: ProtocolSessionState{ + ID: "valid-session", + BaseStationEUI: TestBsEui02, + }, } server.RegisterSession(session) @@ -350,9 +352,11 @@ func Test_PersistSession_ConnectInfoConditional(t *testing.T) { t.Run("NilConnectInfo_PreservesExisting", func(t *testing.T) { session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui04, - ConnectInfo: json.RawMessage(`{"vendor":"existing"}`), + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui04, + ConnectInfo: json.RawMessage(`{"vendor":"existing"}`), + }, } // Simulate PersistSession logic with nil connectInfo @@ -372,9 +376,11 @@ func Test_PersistSession_ConnectInfoConditional(t *testing.T) { t.Run("NonNilConnectInfo_Overwrites", func(t *testing.T) { session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui04, - ConnectInfo: json.RawMessage(`{"vendor":"old"}`), + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui04, + ConnectInfo: json.RawMessage(`{"vendor":"old"}`), + }, } // Simulate PersistSession logic with new connectInfo @@ -391,9 +397,11 @@ func Test_PersistSession_ConnectInfoConditional(t *testing.T) { t.Run("EmptyConnectInfo_Overwrites", func(t *testing.T) { session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui04, - ConnectInfo: json.RawMessage(`{"vendor":"old"}`), + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui04, + ConnectInfo: json.RawMessage(`{"vendor":"old"}`), + }, } // Empty JSON is different from nil - should still overwrite @@ -430,9 +438,11 @@ func Test_SendAttachPropagateBySessionID_SessionSpecific(t *testing.T) { logger: newRecordingLogger(), sessions: map[string]*Session{ "valid-session": { - ID: "valid-session", - BaseStationEUI: TestBsEui02, - Bidirectional: true, + ProtocolSessionState: ProtocolSessionState{ + ID: "valid-session", + BaseStationEUI: TestBsEui02, + }, + Bidirectional: true, }, }, } @@ -475,9 +485,11 @@ func Test_validateOutboundMessage_CatalogTokens(t *testing.T) { server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ - ID: "test-session", - BaseStationEUI: TestBsEui04, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: TestBsEui04, + Encoding: EncodingJSON, + }, } // Test 1: Invalid message missing command field diff --git a/KC-Core/pkg/bssci/protocol_edge_cases_test.go b/KC-Core/pkg/bssci/protocol_edge_cases_test.go index bb9447b..b5ed1be 100644 --- a/KC-Core/pkg/bssci/protocol_edge_cases_test.go +++ b/KC-Core/pkg/bssci/protocol_edge_cases_test.go @@ -89,11 +89,13 @@ func TestBSSCI_2_4_01_forward_compat_extra_fields(t *testing.T) { server.RegisterHandlers() // Register command handlers for CallHandleMessage session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: encoding, - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: encoding, + HandshakeComplete: true, + }, + Conn: mockConn, } // statusRsp with extra unknown field "futureFeature" @@ -156,9 +158,11 @@ func TestBSSCI_5_3_2_minor_version_negotiated_down(t *testing.T) { server.SetConnectionManager(nil) session := &bssci.Session{ - ID: "test-session-negotiate-down", - Conn: mockConn, - Encoding: encoding, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session-negotiate-down", + Encoding: encoding, + }, + Conn: mockConn, } requestedMajor, requestedMinor, _, cerr := bssci.ParseVersion(mioty.MIOTYProtocolVersion) @@ -226,9 +230,11 @@ func TestBSSCI_5_3_1_missing_version_rejected(t *testing.T) { server.SetConfig(config) session := &bssci.Session{ - ID: "test-session", - Conn: mockConn, - Encoding: encoding, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + Encoding: encoding, + }, + Conn: mockConn, } // Connect without the mandatory version field (rev1 §5.3.1; @@ -314,11 +320,13 @@ func TestBSSCI_2_5_02_reject_malformed_values(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: encoding, - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: encoding, + HandshakeComplete: true, + }, + Conn: mockConn, } msg := &bssci.Message{ @@ -365,11 +373,13 @@ func TestBSSCI_2_5_03_numeric_field_types(t *testing.T) { server.RegisterHandlers() // Register command handlers for CallHandleMessage session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: encoding, - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: encoding, + HandshakeComplete: true, + }, + Conn: mockConn, } // All fields are properly typed @@ -431,11 +441,13 @@ func TestNormalizationSentinelErrors(t *testing.T) { mockConn.Reset() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } // statusRsp missing mandatory "code" field @@ -471,11 +483,13 @@ func TestNormalizationSentinelErrors(t *testing.T) { mockConn.Reset() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } // statusRsp with string "code" instead of int64 @@ -508,11 +522,13 @@ func TestNormalizationSentinelErrors(t *testing.T) { mockConn.Reset() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } // ulData with responseExp=true but dlOpen=false - conditional rule violation @@ -555,11 +571,13 @@ func TestNormalizationSentinelErrors(t *testing.T) { mockConn.Reset() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } // For now, use same responseExp scenario but verify it doesn't match generic token @@ -596,9 +614,10 @@ func TestNormalizationSentinelErrors(t *testing.T) { }) } -// TestConConditionalRules verifies MIOTY radio spec §3.6.5.3 session resume mutual presence validation -// Tests the ConditionalRules added to con command in message_metadata.go:669-688 -func TestConConditionalRules(t *testing.T) { +// TestConResumeCountersIndependentlyOptional verifies BSSCI rev1 §5.3.1: +// snBsOpId and snScOpId are each independently optional in the con message. +// Any combination (neither, either alone, both) must be accepted. +func TestConResumeCountersIndependentlyOptional(t *testing.T) { testLogger := logger.NewNop() sessionSvc, downlinkSvc, statusSvc, _, broadcaster, @@ -623,11 +642,14 @@ func TestConConditionalRules(t *testing.T) { server.RegisterHandlers() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: false, // Fresh connection + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: false, + }, + Conn: mockConn, + // Fresh connection } // New connection without resume fields - should succeed @@ -661,14 +683,16 @@ func TestConConditionalRules(t *testing.T) { server.RegisterHandlers() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: false, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: false, + }, + Conn: mockConn, } - // Resume with BOTH snBsOpId and snScOpId - mutual presence satisfied + // Both counters together assert both constraints data := map[string]interface{}{ "version": "1.0.0", "bsEui": bssci.TestBsEui01, @@ -695,27 +719,28 @@ func TestConConditionalRules(t *testing.T) { assert.Equal(t, "conRsp", mockConn.sentMessages[0]["command"]) }) - t.Run("Resume_OnlySnBsOpId_FailsConditionalRule", func(t *testing.T) { + t.Run("Resume_OnlySnBsOpId_Accepted", func(t *testing.T) { mockConn := &edgeMockConn{encoding: "json"} mockConn.Reset() server.RegisterHandlers() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: false, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: false, + }, + Conn: mockConn, } - // Resume with ONLY snBsOpId - violates mutual presence rule + // snBsOpId alone asserts only the BS-counter constraint (§5.3.1) data := map[string]interface{}{ "version": "1.0.0", "bsEui": bssci.TestBsEui01, "bidi": true, "snBsUuid": []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, - "snBsOpId": int64(100), // BS counter present - // snScOpId MISSING - triggers conditional rule failure + "snBsOpId": int64(100), } msg := &bssci.Message{ @@ -725,39 +750,35 @@ func TestConConditionalRules(t *testing.T) { } err := server.CallHandleMessage(session, msg, data) - assert.NoError(t, err, "handleMessage should not return error (sends error frame instead)") + assert.NoError(t, err, "handleMessage should accept snBsOpId without snScOpId") - // Should send error frame (not conRsp) - require.Len(t, mockConn.sentMessages, 1) - assert.Equal(t, "error", mockConn.sentMessages[0]["command"]) - - errorMessage, ok := mockConn.sentMessages[0]["message"].(string) - require.True(t, ok) - assert.Equal(t, "Conditional field requirement failed", errorMessage, - "Error response should use catalog message without leaking rule text") + require.GreaterOrEqual(t, len(mockConn.sentMessages), 1) + assert.Equal(t, "conRsp", mockConn.sentMessages[0]["command"], + "a lone snBsOpId is a valid resume constraint") }) - t.Run("Resume_OnlySnScOpId_FailsConditionalRule", func(t *testing.T) { + t.Run("Resume_OnlySnScOpId_Accepted", func(t *testing.T) { mockConn := &edgeMockConn{encoding: "json"} mockConn.Reset() server.RegisterHandlers() session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: false, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: false, + }, + Conn: mockConn, } - // Resume with ONLY snScOpId - violates mutual presence rule + // snScOpId alone asserts only the SC-counter constraint (§5.3.1) data := map[string]interface{}{ "version": "1.0.0", "bsEui": bssci.TestBsEui01, "bidi": true, "snBsUuid": []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, - "snScOpId": int64(-50), // SC counter present - // snBsOpId MISSING - triggers conditional rule failure + "snScOpId": int64(-50), } msg := &bssci.Message{ @@ -767,16 +788,11 @@ func TestConConditionalRules(t *testing.T) { } err := server.CallHandleMessage(session, msg, data) - assert.NoError(t, err, "handleMessage should not return error (sends error frame instead)") - - // Should send error frame (not conRsp) - require.Len(t, mockConn.sentMessages, 1) - assert.Equal(t, "error", mockConn.sentMessages[0]["command"]) + assert.NoError(t, err, "handleMessage should accept snScOpId without snBsOpId") - errorMessage, ok := mockConn.sentMessages[0]["message"].(string) - require.True(t, ok) - assert.Equal(t, "Conditional field requirement failed", errorMessage, - "Error response should use catalog message without leaking rule text") + require.GreaterOrEqual(t, len(mockConn.sentMessages), 1) + assert.Equal(t, "conRsp", mockConn.sentMessages[0]["command"], + "a lone snScOpId is a valid resume constraint") }) } @@ -911,11 +927,13 @@ func TestNormalizeResponseCommandsWithUnknownFields(t *testing.T) { server.RegisterHandlers() // Register command handlers for CallHandleMessage session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: encoding, - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: encoding, + HandshakeComplete: true, + }, + Conn: mockConn, } msg := &bssci.Message{ diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index 28fadf4..06fcaa1 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -202,8 +202,12 @@ type Config struct { DetachSignatureValidationEnabled bool // BSSCI §5.7.1: Enable detach signature validation // OperationAckTimeout bounds how long the service center waits for the // next handshake message (conCmp or errorAck) after sending conRsp or a - // connect-stage error. Also bounds reads before the connect operation. + // connect-stage error (states AwaitingConnectComplete/AwaitingConnectErrorAck). OperationAckTimeout time.Duration + // ConnectionEstablishmentTimeout bounds a freshly accepted connection + // before the base station sends its con (state AwaitingConnect), so an + // idle socket cannot hold resources indefinitely. + ConnectionEstablishmentTimeout time.Duration // DuplicateWindow is the uplink deduplication window DuplicateWindow time.Duration // CertificatePollInterval is the certificate change poll interval @@ -233,29 +237,19 @@ const ( ConnectStateTerminal ) -// Session represents a connected Base Station session +// ProtocolSessionState is the transport-free domain state of a Base Station +// session: identity, negotiated protocol parameters, resume/handshake state, +// and operation counters. Application services (connect, lifecycle, resume) +// operate on this state and never on the transport-bearing Session. // //revive:disable:var-naming BsOpId/ScOpId/LastBsOpId/LastScOpId use lowercase 'd' per MIOTY BSSCI §3.2 -type Session struct { +type ProtocolSessionState struct { ID string BaseStationEUI uint64 - Conn net.Conn - Connected time.Time - LastSeen time.Time ClientVersion string // BS-provided version (raw client claim for audit) NegotiatedVersion string // SC canonical version (BSSCI §4-4.5) - Vendor string - Model string - Name string - SoftwareVersion string - Bidirectional bool - GeoLocation []float64 SessionUUID []byte - // MIOTY session fields - DbSessionID int64 // Database session ID (BIGINT) for persistence - UserProvidedName string // User-provided base station name - ActiveVMTypes map[uint64][]uint8 // Track active Variable MAC types per endpoint - stopStatus chan struct{} // Channel to stop status mechanism + DbSessionID int64 // Database session ID (BIGINT) for persistence // Session resume fields (BSSCI-3.3) BsUUID []byte // Base Station UUID for session resume BsOpId int64 // Last known Base Station operation ID @@ -270,15 +264,36 @@ type Session struct { // Message encoding (BSSCI Section 1) Encoding string // Message encoding: "json" or "msgpack" (negotiated on first message) // Organization resolution - OrganizationID uuid.UUID // Kilo Cloud org UUID (from TLS cert or community fallback) - ResolvedTenantID int64 // Tenant ID resolved from cert/org (vs server default s.tenantID) - ClientCert *x509.Certificate // TLS client certificate for org resolution + OrganizationID uuid.UUID // Kilo Cloud org UUID (from TLS cert or community fallback) + ResolvedTenantID int64 // Tenant ID resolved from cert/org (vs server default s.tenantID) // Connect handshake state machine (BSSCI §3.3/§5.17) ConnectState ConnectState + // mu is the domain concurrency guard for counter/state mutation. + mu sync.Mutex +} + +// Session represents a connected Base Station session: the transport-free +// ProtocolSessionState plus the live transport resources (socket, certificate, +// background channels) that never cross an application-service boundary. +type Session struct { + ProtocolSessionState + Conn net.Conn + Connected time.Time + LastSeen time.Time + Vendor string + Model string + Name string + SoftwareVersion string + Bidirectional bool + GeoLocation []float64 + // MIOTY session fields + UserProvidedName string // User-provided base station name + ActiveVMTypes map[uint64][]uint8 // Track active Variable MAC types per endpoint + stopStatus chan struct{} // Channel to stop status mechanism + ClientCert *x509.Certificate // TLS client certificate for org resolution // pendingBaseStation caches the registration looked up during the connect // request so connect-complete does not repeat the lookup pendingBaseStation *basestation.BaseStation - mu sync.Mutex } // BaseStationEUIBytes converts the BaseStationEUI uint64 to a byte slice. @@ -812,7 +827,6 @@ func (s *Server) registerHandlers() { s.handlers[mioty.CmdPingComplete] = s.handlePingComplete // Status response handlers (SC-initiated, so no status handler for BS-initiated) s.handlers[mioty.CmdStatusResponse] = s.handleStatusResponse - s.handlers[mioty.CmdStatusComplete] = s.handleStatusComplete s.handlers[mioty.CmdAttach] = s.handleAttach s.handlers[mioty.CmdAttachComplete] = s.handleAttachComplete s.handlers[mioty.CmdDetach] = s.handleDetach @@ -821,14 +835,11 @@ func (s *Server) registerHandlers() { s.handlers[mioty.CmdULDataComplete] = s.handleULDataComplete // UL Data Transmit response handlers (SC-initiated, so no ulDataTx handler) s.handlers[mioty.CmdULDataTransmitResponse] = s.handleULDataTxResponse - s.handlers[mioty.CmdULDataTransmitComplete] = s.handleULDataTxComplete s.handlers[mioty.CmdError] = s.handleError s.handlers[mioty.CmdErrorAck] = s.handleErrorAck // Attach/Detach Propagate handlers s.handlers[mioty.CmdAttachPropagateResponse] = s.handleAttachPropagateResponse - s.handlers[mioty.CmdAttachPropagateComplete] = s.handleAttachPropagateComplete s.handlers[mioty.CmdDetachPropagateResponse] = s.handleDetachPropagateResponse - s.handlers[mioty.CmdDetachPropagateComplete] = s.handleDetachPropagateComplete // DL Data Result handlers (BSSCI §3.14) s.handlers[mioty.CmdDLDataResult] = s.handleDLDataResult s.handlers[mioty.CmdDLDataResultResponse] = s.handleDLDataResultResponse @@ -839,13 +850,10 @@ func (s *Server) registerHandlers() { s.handlers[mioty.CmdDLRxStatusComplete] = s.handleDLRXStatusComplete // DL RX Status Query response handlers (BSSCI §3.16 - SC-initiated, so no dlRxStatQry handler) s.handlers[mioty.CmdDLRxStatusQueryResponse] = s.handleDLRXStatusQueryResponse - s.handlers[mioty.CmdDLRxStatusQueryComplete] = s.handleDLRXStatusQueryComplete // DL Data Revoke response handlers (BSSCI §3.13 - SC-initiated) s.handlers[mioty.CmdDLDataRevokeResponse] = s.handleDLDataRevokeResponse - s.handlers[mioty.CmdDLDataRevokeComplete] = s.handleDLDataRevokeComplete // DL Data Queue response handlers (BSSCI §3.12 - SC-initiated) s.handlers[mioty.CmdDLDataQueueResponse] = s.handleDLDataQueueResponse - s.handlers[mioty.CmdDLDataQueueComplete] = s.handleDLDataQueueComplete // VM handlers (BSSCI §4.1-4.3) s.handlers[mioty.CmdVMActivate] = s.handleVMActivate s.handlers[mioty.CmdVMActivateResponse] = s.handleVMActivateResponse @@ -1014,12 +1022,14 @@ func (s *Server) handleConnection(conn net.Conn) { s.logger.InfoContext(s.safeCtx(), LogBSSCINewConnection, "remote", conn.RemoteAddr().String()) session := &Session{ - ID: uuid.New().String(), - Conn: conn, - Connected: time.Now(), - LastSeen: time.Now(), - ResolvedTenantID: s.defaultTenantID, // Initialize to server default - // Encoding left empty - detected on first frame per BSSCI Section 1 + ProtocolSessionState: ProtocolSessionState{ + ID: uuid.New().String(), + ResolvedTenantID: s.defaultTenantID, // Initialize to server default + // Encoding left empty - detected on first frame per BSSCI Section 1 + }, + Conn: conn, + Connected: time.Now(), + LastSeen: time.Now(), } // Extract TLS client certificate and resolve organization @@ -1167,8 +1177,11 @@ func (s *Server) handleConnection(conn net.Conn) { } } - // Clean up pending operations for this session (bulk delete) - if session.DbSessionID != 0 && s.storage != nil { + // Pending operations survive an unexpected loss of an active session: + // they are reissued with their original opIds on resume (BSSCI §4/§5.3). + // Only a terminal session (rejected, provisional, or terminated above) + // has its rows removed. + if session.DbSessionID != 0 && s.storage != nil && !wasActive { // Guard against nil repository before calling methods pendingOpsRepo := s.storage.PendingOperations() if pendingOpsRepo != nil { @@ -1194,13 +1207,19 @@ func (s *Server) handleConnection(conn net.Conn) { default: } - // Read deadline follows the handshake state: bounded while the connect - // operation is in flight (con/conRsp/error awaiting their next message), - // cleared once the session is active - liveness of active sessions is + // Read deadline follows the handshake state: a fresh connection is + // bounded by the establishment timeout until con arrives; after conRsp + // or a connect-stage error the ack timeout bounds the wait for conCmp + // or errorAck; an active session reads without a deadline - liveness is // the ping operation's job (BSSCI §5.4) - deadline := time.Time{} - if session.ConnectState != ConnectStateComplete { + var deadline time.Time + switch session.ConnectState { + case ConnectStateAwaitingConnect: + deadline = time.Now().Add(s.connectionEstablishmentTimeout()) + case ConnectStateAwaitingConnectComplete, ConnectStateAwaitingConnectErrorAck: deadline = time.Now().Add(s.operationAckTimeout()) + default: + // Complete/Terminal: no read deadline } if err := conn.SetReadDeadline(deadline); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSetReadDeadline, "error", err) @@ -1395,14 +1414,16 @@ func (s *Server) handleMessage(session *Session, msg *Message, data map[string]i errToken = errConditionalRuleFailed } + // During the connect handshake a normalization failure enters the + // unified error sequence: the error replaces conRsp/conCmp and + // awaits errorAck (§5.17), and a failed error write terminates the + // connection. After activation it is a per-operation error. + if !session.HandshakeComplete { + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errToken) + } if sendErr := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errToken)); sendErr != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToSendErrorResponse, "error", sendErr) } - // A malformed connect request enters the handshake error sequence: - // the error above replaces conRsp and awaits errorAck (§5.17) - if msg.Command == mioty.CmdConnect && session.ConnectState == ConnectStateAwaitingConnect { - session.ConnectState = ConnectStateAwaitingConnectErrorAck - } return nil // Error sent via protocol, don't close connection } // Use normalized data for handler (unknown fields removed, types coerced) @@ -1412,9 +1433,12 @@ func (s *Server) handleMessage(session *Session, msg *Message, data map[string]i // BSSCI-3.3-03: No operations allowed until connect handshake is complete // Only con, conRsp, conCmp, error, and errorAck are permitted during handshake if !session.HandshakeComplete { + // conRsp is SC-initiated (SCtoBS): the base station must never send it. + // Only con, conCmp, error, and errorAck are legal inbound during the + // handshake; anything else - including an inbound conRsp - is a + // protocol-ordering violation. allowedCommands := map[string]bool{ mioty.CmdConnect: true, - mioty.CmdConnectResponse: true, mioty.CmdConnectComplete: true, mioty.CmdError: true, mioty.CmdErrorAck: true, @@ -1423,14 +1447,12 @@ func (s *Server) handleMessage(session *Session, msg *Message, data map[string]i if !allowedCommands[msg.Command] { s.logger.WarnContext(s.safeCtx(), LogBSSCIRejectingCommandBeforeHandshake, "command", msg.Command, - "handshake_complete", session.HandshakeComplete, + "connectState", int(session.ConnectState), "opId", msg.OpId, "bsEui", session.BaseStationEUI) - if err := s.sendError(session, msg.OpId, POSIX_EPROTO, - ResolveErrorMessage(errCommandBeforeHandshake)); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) - } - return nil // Don't close connection, just reject the command + // Route through the unified reject so the error is followed by an + // errorAck exchange (§5.17) and a failed write terminates cleanly + return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errCommandBeforeHandshake) } } @@ -1447,6 +1469,25 @@ func (s *Server) handleMessage(session *Session, msg *Message, data map[string]i } } + // Direction enforcement (BSSCI §5.5/§5.11-5.16): a command the service + // center itself sends (DirectionSCtoBS) must never be processed inbound. + // A base station sending e.g. statusCmp or dlDataQueCmp is a protocol + // violation. The vm.* sublayer is exempt here - its enforcement lands with + // the ECE-reserved VM work. Connect-stage SCtoBS handling (conRsp) is + // already covered by the pre-handshake gate above. + if !strings.HasPrefix(msg.Command, "vm.") { + if cmdDirection, known := CommandDirectionMap[msg.Command]; known && cmdDirection == DirectionSCtoBS { + s.logger.WarnContext(s.safeCtx(), LogBSSCIRejectingInboundServiceCenterCommand, + "command", msg.Command, + "opId", msg.OpId, + "bsEui", session.BaseStationEUI) + if err := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errInboundServiceCenterCommand)); err != nil { + s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) + } + return nil // Don't close connection + } + } + handler, ok := s.handlers[msg.Command] if !ok { // Send BSSCI error message instead of closing socket (BSSCI-4-01) @@ -1530,6 +1571,15 @@ func (s *Server) operationAckTimeout() time.Duration { return defaultOperationAckTimeout } +// connectionEstablishmentTimeout returns the configured bound for a freshly +// accepted connection to send its con, falling back to the package default. +func (s *Server) connectionEstablishmentTimeout() time.Duration { + if s.config != nil && s.config.ConnectionEstablishmentTimeout > 0 { + return s.config.ConnectionEstablishmentTimeout + } + return defaultConnectionEstablishmentTimeout +} + // duplicateWindow returns the configured uplink deduplication window, falling // back to the package default when unset. func duplicateWindow(cfg *Config) time.Duration { @@ -1697,7 +1747,6 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m // BSSCI-3.3: Session resume handling var snResume bool var previousSession *Session - var err error // Resume identity is snBsUuid scoped by tenant and base station EUI // (rev1 §5.3.1: the con message carries snBsUuid, snBsOpId, snScOpId - @@ -1721,22 +1770,36 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m scOpId = &scOp } - // Delegate resume validation to SessionService - previousSession, err = s.sessionSvc.HandleResume(ctx, session, bsUUID, bsOpId, scOpId, session.BaseStationEUI) - if err != nil { - // Log error but continue with new session - s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToCheckSessionResume, - "error", err, + // Delegate resume validation to SessionService, which returns a typed + // outcome so infrastructure failures and inconsistent state never + // silently degrade into a fresh session + outcome := s.sessionSvc.HandleResume(ctx, session, bsUUID, bsOpId, scOpId, session.BaseStationEUI) + switch outcome.Disposition { + case ResumeInfrastructureFailure: + // A lookup failure must reject the connect before conRsp; the old + // resumable state stays intact for a later successful resume + s.logger.ErrorContext(ctx, LogBSSCIFailedToCheckSessionResume, + "error", outcome.Err, "eui", session.BaseStationEUI) - } - - // A resumable session must belong to the tenant authorized above; - // cross-tenant UUID collisions never resume - if previousSession != nil && previousSession.ResolvedTenantID != session.ResolvedTenantID { - s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToCheckSessionResume, - "eui", session.BaseStationEUI, - "sessionTenant", session.ResolvedTenantID, - "previousTenant", previousSession.ResolvedTenantID) + return s.rejectConnect(session, msg.OpId, POSIX_EAGAIN, errSessionResumeUnavailable) + case ResumeInconsistent: + // Incompatible counters or version: atomically terminate the old + // session (can_resume=false, pending ops removed) then start fresh + s.logger.WarnContext(ctx, LogBSSCIFailedToCheckSessionResume, + "error", outcome.Err, + "eui", session.BaseStationEUI) + if outcome.Previous != nil { + if err := s.terminateInconsistentResume(ctx, outcome.Previous); err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToTerminateSession, + "error", err, + "eui", session.BaseStationEUI) + return s.rejectConnect(session, msg.OpId, POSIX_EAGAIN, errSessionResumeUnavailable) + } + } + previousSession = nil + case ResumeCompatible: + previousSession = outcome.Previous + default: previousSession = nil } @@ -1831,6 +1894,25 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m return nil } +// terminateInconsistentResume atomically retires a resumable session whose +// reported counters or negotiated version are incompatible with the current +// connect: it terminates the session (status=terminated, can_resume=false) +// and removes its pending operations so the base station starts genuinely +// fresh. The previous session is a DB-hydrated snapshot, never a live one. +func (s *Server) terminateInconsistentResume(ctx context.Context, previous *Session) error { + if err := s.sessionSvc.TerminateSession(ctx, previous); err != nil { + return fmt.Errorf("terminate inconsistent resume session: %w", err) + } + if previous.DbSessionID != 0 && s.storage != nil { + if pendingOpsRepo := s.storage.PendingOperations(); pendingOpsRepo != nil { + if _, err := pendingOpsRepo.DeleteBySession(ctx, previous.DbSessionID); err != nil { + return fmt.Errorf("remove pending operations of inconsistent resume session: %w", err) + } + } + } + return nil +} + // handleConnectComplete handles the connect complete operation. The connect // operation is already complete at the protocol level when conCmp arrives, so // failures past this point never send a BSSCI error - partial persistence is @@ -1864,8 +1946,14 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message return fmt.Errorf("%s: EUI %016X", ResolveErrorMessage(errBaseStationNotRegistered), session.BaseStationEUI) } - // Persist the session; the operation is protocol-complete, so a - // persistence failure closes the connection without a BSSCI error + // Activation is one application operation: persist the session and register + // the live connection/status BEFORE publishing to the in-memory registries. + // The operation is protocol-complete, so any failure closes the connection + // without a BSSCI error - partial state is compensated and the base station + // reconnects. + + // Step 1: persist the session row (terminates a stale active session first, + // aborting on failure rather than leaving two active sessions) if err := s.sessionSvc.PersistSession(ctx, session, baseStation, session.IsResumed, session.ConnectInfo); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToPersistSession, "error", err, @@ -1874,14 +1962,31 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message return fmt.Errorf("session persistence failed after conCmp: %w", err) } - // Activation: only a fully persisted session enters the live-session map + // Step 2: register the live connection and base-station online status. A + // failure here compensates the just-persisted session and closes without + // publishing anything to the live registries. + if err := s.connectionSvc.RegisterConnection(ctx, session, baseStation, s.connectionMgr); err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateConnectionStatus, + "eui", session.BaseStationEUI, + "error", err) + if termErr := s.sessionSvc.TerminateSession(ctx, session); termErr != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToTerminateSession, + "error", termErr, + "eui", session.BaseStationEUI) + } + session.ConnectState = ConnectStateTerminal + return fmt.Errorf("connection registration failed after conCmp: %w", err) + } + + // Step 3: both durable steps succeeded - publish to the live-session map // and the resumable index s.mu.Lock() s.sessions[session.ID] = session s.mu.Unlock() s.sessionSvc.StoreSessionByUUID(session) - // Update the base station's bidi capability and GPS location in the database + // Update the base station's bidi capability and GPS location in the + // database (non-critical enrichment; failure is logged, not fatal) if s.basestationRepo != nil { updates := map[string]interface{}{ "bidi": session.Bidirectional, @@ -1906,13 +2011,6 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message } } - // Delegate connection registration to ConnectionService - if err := s.connectionSvc.RegisterConnection(ctx, session, baseStation, s.connectionMgr); err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateConnectionStatus, - "eui", session.BaseStationEUI, - "error", err) - } - // BSSCI-3.3-03: Delegate handshake completion to SessionService s.sessionSvc.MarkHandshakeComplete(session) session.ConnectState = ConnectStateComplete @@ -3941,101 +4039,37 @@ func (s *Server) handleError(_ *Server, session *Session, msg *Message, data map return fmt.Errorf("base station rejected connect response: code=%d %s", code, message) } - // Check if this error is related to a pending operation - // Base stations sometimes send error messages instead of proper response messages - // We must complete the three-way handshake to prevent timeout loops - // BSSCI §§5.11-5.12.3 Gap 1: Use StatusService for pending operation access - var pendingOp *PendingOperation - var err error - if s.statusSvc != nil { - pendingOp, err = s.statusSvc.GetPendingOperation(session, int64(msg.OpId)) + // BSSCI §5.17: an inbound error is answered ONLY with errorAck - never with + // an operation-specific *Cmp, and the operation type is never guessed. The + // error and errorAck replace the normal response/completion sequence. + errorAckMsg := map[string]interface{}{ + "command": mioty.CmdErrorAck, + "opId": msg.OpId, + } + if sendErr := s.sendMessage(session, errorAckMsg); sendErr != nil { + s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorAck, "error", sendErr) + return sendErr } - hasPendingOp := err == nil - - // For Service Center initiated operations (negative opId), always try to complete - // the handshake even if we don't have the operation in our tracking - isServiceCenterOp := msg.OpId < 0 - - if (hasPendingOp && pendingOp != nil) || isServiceCenterOp { - // Determine operation type - var operationType string - if pendingOp != nil { - operationType = pendingOp.OperationType - } else { - // For Service Center operations without tracking, guess based on recent state - // Service Center uses negative opIds for attach/detach propagate operations - // This is our best guess - most SC operations are propagate operations - if msg.OpId < 0 { - // Default to detach propagate as it's most common for error scenarios - operationType = mioty.CmdDetachPropagate - } else { - operationType = "unknown" - } - } - - s.logger.InfoContext(s.safeCtx(), LogBSSCICompletingThreeWayHandshakeForError, - "opId", msg.OpId, - "operationType", operationType) - - // Send appropriate completion message based on operation type - var completionCommand string - switch operationType { - case mioty.CmdAttachPropagate: - completionCommand = mioty.CmdAttachPropagateComplete - case mioty.CmdDetachPropagate: - completionCommand = mioty.CmdDetachPropagateComplete - default: - // For other operations, acknowledge with errorAck per BSSCI-4-02 - errorAckMsg := map[string]interface{}{ - "command": mioty.CmdErrorAck, - "opId": msg.OpId, - } - if err := s.sendMessage(session, errorAckMsg); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorAck, "error", err) - } - return nil - } - - // Send completion message - completionMsg := map[string]interface{}{ - "command": completionCommand, - "opId": msg.OpId, - } - if err := s.sendMessage(session, completionMsg); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendCompletionMessageForErrorOperation, - "error", err, - "command", completionCommand) - return err + // Typed compensation only for an operation this service center is actually + // tracking (an errored operation is finalized without updating domain + // state). An unmatched error is acknowledged and audited but touches no + // unrelated pending state. + var pendingOp *PendingOperation + if s.statusSvc != nil { + if op, lookupErr := s.statusSvc.GetPendingOperation(session, int64(msg.OpId)); lookupErr == nil { + pendingOp = op } - - // For error cases, we do NOT call the completion handler - // The completion handler should only be called for successful operations - // When the base station sends an error (e.g., "unknown endpoint"), - // we complete the handshake but don't update our database state + } + if pendingOp != nil { s.logger.InfoContext(s.safeCtx(), LogBSSCIErrorOperationHandshakeCompletedDatabaseNotUpdated, "opId", msg.OpId, - "operationType", operationType) - - // Clean up the pending operation from database and memory - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both cache and DB removal + "operationType", pendingOp.OperationType) if err := s.removePendingOperation(session, msg.OpId); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperationFromDatabase, "error", err, "opId", msg.OpId) } - // Note: No manual fallback - trust StatusService single-writer pattern - - return nil - } - - // For non-pending operations, just acknowledge per BSSCI-4-02 - errorAckMsg := map[string]interface{}{ - "command": mioty.CmdErrorAck, - "opId": msg.OpId, - } - if err := s.sendMessage(session, errorAckMsg); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorAck, "error", err) } return nil diff --git a/KC-Core/pkg/bssci/session_lookup_test.go b/KC-Core/pkg/bssci/session_lookup_test.go index 367ff02..91e9ae5 100644 --- a/KC-Core/pkg/bssci/session_lookup_test.go +++ b/KC-Core/pkg/bssci/session_lookup_test.go @@ -38,14 +38,16 @@ func createTestServerWithSessions(t *testing.T, sessions []*Session) *Server { // where a session is found with correct handshake and bidirectional state func TestFindSessionForEndpointAttachment_Success(t *testing.T) { session := &Session{ - ID: "test-session-ready", - BaseStationEUI: TestBsEui04, - HandshakeComplete: true, - Bidirectional: true, - Connected: time.Now(), - LastSeen: time.Now(), - ClientVersion: "1.0.0", - NegotiatedVersion: "1.0.0", + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-ready", + BaseStationEUI: TestBsEui04, + HandshakeComplete: true, + ClientVersion: "1.0.0", + NegotiatedVersion: "1.0.0", + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), } server := createTestServerWithSessions(t, []*Session{session}) @@ -75,12 +77,15 @@ func TestFindSessionForEndpointAttachment_SessionNotFound(t *testing.T) { // when session exists but doesn't support bidirectional operations func TestFindSessionForEndpointAttachment_NotBidirectional(t *testing.T) { session := &Session{ - ID: "unidirectional-session", - BaseStationEUI: TestBsEui02, - HandshakeComplete: true, - Bidirectional: false, // Not bidirectional - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "unidirectional-session", + BaseStationEUI: TestBsEui02, + HandshakeComplete: true, + }, + Bidirectional: false, + // Not bidirectional + Connected: time.Now(), + LastSeen: time.Now(), } server := createTestServerWithSessions(t, []*Session{session}) @@ -97,12 +102,15 @@ func TestFindSessionForEndpointAttachment_NotBidirectional(t *testing.T) { // when session exists but handshake hasn't completed (BSSCI §3.3) func TestFindSessionForEndpointAttachment_HandshakeIncomplete(t *testing.T) { session := &Session{ - ID: "incomplete-handshake", - BaseStationEUI: TestEpEui01, - HandshakeComplete: false, // Handshake not complete - Bidirectional: true, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "incomplete-handshake", + BaseStationEUI: TestEpEui01, + HandshakeComplete: false, + }, + // Handshake not complete + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), } server := createTestServerWithSessions(t, []*Session{session}) @@ -136,12 +144,14 @@ func TestFindSessionForEndpointAttachment_ErrorSentinels(t *testing.T) { { name: "SessionNotReady sentinel", session: &Session{ - ID: "not-ready", - BaseStationEUI: TestBsEuiMulti01, - HandshakeComplete: false, - Bidirectional: true, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "not-ready", + BaseStationEUI: TestBsEuiMulti01, + HandshakeComplete: false, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, bsEui: TestBsEuiMulti01, expectedSentinel: ErrSessionNotReady, @@ -150,12 +160,14 @@ func TestFindSessionForEndpointAttachment_ErrorSentinels(t *testing.T) { { name: "SessionNotBidirectional sentinel", session: &Session{ - ID: "not-bidi", - BaseStationEUI: TestBsEuiMulti02, - HandshakeComplete: true, - Bidirectional: false, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "not-bidi", + BaseStationEUI: TestBsEuiMulti02, + HandshakeComplete: true, + }, + Bidirectional: false, + Connected: time.Now(), + LastSeen: time.Now(), }, bsEui: TestBsEuiMulti02, expectedSentinel: ErrSessionNotBidirectional, @@ -188,28 +200,34 @@ func TestFindSessionForEndpointAttachment_ErrorSentinels(t *testing.T) { func TestFindSessionForEndpointAttachment_MultipleSessionsPicksCorrect(t *testing.T) { sessions := []*Session{ { - ID: "session-1", - BaseStationEUI: TestBsEuiMulti01, - HandshakeComplete: true, - Bidirectional: true, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-1", + BaseStationEUI: TestBsEuiMulti01, + HandshakeComplete: true, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, { - ID: "session-2-target", - BaseStationEUI: TestBsEuiMulti02, - HandshakeComplete: true, - Bidirectional: true, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-2-target", + BaseStationEUI: TestBsEuiMulti02, + HandshakeComplete: true, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, { - ID: "session-3", - BaseStationEUI: TestBsEuiMulti03, - HandshakeComplete: true, - Bidirectional: true, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-3", + BaseStationEUI: TestBsEuiMulti03, + HandshakeComplete: true, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, } @@ -235,31 +253,37 @@ func TestSelectBidirectionalSession_DeterministicFallback(t *testing.T) { // The session with lowest EUI (0x0001) should always be selected sessions := []*Session{ { - ID: "session-high", - BaseStationEUI: 0x0000000000009999, // High EUI - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenantID, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-high", + BaseStationEUI: 0x0000000000009999, + HandshakeComplete: true, + ResolvedTenantID: tenantID, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, { - ID: "session-lowest", - BaseStationEUI: 0x0000000000000001, // Lowest EUI - should be selected - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenantID, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-lowest", + BaseStationEUI: 0x0000000000000001, + HandshakeComplete: true, + ResolvedTenantID: tenantID, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, { - ID: "session-mid", - BaseStationEUI: 0x0000000000005555, // Mid EUI - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenantID, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-mid", + BaseStationEUI: 0x0000000000005555, + HandshakeComplete: true, + ResolvedTenantID: tenantID, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, } @@ -286,31 +310,37 @@ func TestSelectBidirectionalSession_DeterministicFallback_TenantIsolation(t *tes sessions := []*Session{ { - ID: "tenant2-lowest", - BaseStationEUI: 0x0000000000000001, // Lowest overall, but wrong tenant - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenant2, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "tenant2-lowest", + BaseStationEUI: 0x0000000000000001, // Lowest overall, but wrong tenant + HandshakeComplete: true, + ResolvedTenantID: tenant2, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, { - ID: "tenant1-lowest", - BaseStationEUI: 0x0000000000000100, // Lowest for tenant1 - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenant1, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "tenant1-lowest", + BaseStationEUI: 0x0000000000000100, + HandshakeComplete: true, + ResolvedTenantID: tenant1, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, { - ID: "tenant1-higher", - BaseStationEUI: 0x0000000000000200, // Higher for tenant1 - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenant1, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "tenant1-higher", + BaseStationEUI: 0x0000000000000200, + HandshakeComplete: true, + ResolvedTenantID: tenant1, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, } @@ -334,22 +364,26 @@ func TestSelectBidirectionalSession_SpecificBsEui(t *testing.T) { sessions := []*Session{ { - ID: "session-lowest", - BaseStationEUI: 0x0000000000000001, // Lowest EUI - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenantID, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-lowest", + BaseStationEUI: 0x0000000000000001, + HandshakeComplete: true, + ResolvedTenantID: tenantID, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, { - ID: "session-target", - BaseStationEUI: targetEui, // Requested target - HandshakeComplete: true, - Bidirectional: true, - ResolvedTenantID: tenantID, - Connected: time.Now(), - LastSeen: time.Now(), + ProtocolSessionState: ProtocolSessionState{ + ID: "session-target", + BaseStationEUI: targetEui, + HandshakeComplete: true, + ResolvedTenantID: tenantID, + }, + Bidirectional: true, + Connected: time.Now(), + LastSeen: time.Now(), }, } diff --git a/KC-Core/pkg/bssci/session_opid_rollback_test.go b/KC-Core/pkg/bssci/session_opid_rollback_test.go index e15818f..d1a6180 100644 --- a/KC-Core/pkg/bssci/session_opid_rollback_test.go +++ b/KC-Core/pkg/bssci/session_opid_rollback_test.go @@ -44,8 +44,10 @@ func TestAtomicSendRollback(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: tt.initialOpId, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: tt.initialOpId, + }, } // Step 1: Decrement operation ID (atomic send-and-persist pattern) @@ -76,8 +78,10 @@ func TestAtomicSendRollback(t *testing.T) { // correctly rollback operation IDs without causing counter drift. func TestMultipleRollbacks(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: 0, + }, } numAttempts := 5 @@ -112,8 +116,10 @@ func TestMultipleRollbacks(t *testing.T) { // a mix of successful and failed operations, maintaining counter integrity. func TestMixedSuccessFailure(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: 0, + }, } operations := []struct { @@ -153,8 +159,10 @@ func TestMixedSuccessFailure(t *testing.T) { // operation ID doesn't affect tracking of other pending operations. func TestRollbackPreservesPendingOperations(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: 0, + }, } // Track pending operations manually (simulating pendingOps map) @@ -202,8 +210,10 @@ func TestRollbackPreservesPendingOperations(t *testing.T) { // under concurrent access scenarios. func TestRollbackWithConcurrency(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: 0, + }, } numGoroutines := 10 @@ -265,8 +275,10 @@ func TestRollbackWithConcurrency(t *testing.T) { // across rollback scenarios, maintaining BSSCI §5.2 correctness. func TestRollbackIntegrity(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: 0, + }, } usedOpIds := make(map[int64]bool) diff --git a/KC-Core/pkg/bssci/session_resume_opid_test.go b/KC-Core/pkg/bssci/session_resume_opid_test.go index ff83f79..1e47ae2 100644 --- a/KC-Core/pkg/bssci/session_resume_opid_test.go +++ b/KC-Core/pkg/bssci/session_resume_opid_test.go @@ -27,7 +27,9 @@ func TestSessionOpIdInitialization(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Create new session session := &Session{ - BaseStationEUI: TestEpEui01, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + }, } // Verify initial counter values @@ -75,8 +77,10 @@ func TestSessionOpIdDecrement(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: tt.initialOpId, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: tt.initialOpId, + }, } // Simulate sending operations using atomic pattern @@ -141,8 +145,10 @@ func TestSessionOpIdIncrement(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastBsOpId: tt.initialOpId, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastBsOpId: tt.initialOpId, + }, } // Simulate receiving BS operations and validating them @@ -210,9 +216,11 @@ func TestSessionOpIdResume(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Simulate session resume by creating session with saved counters session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: tt.savedScOpId, - LastBsOpId: tt.savedBsOpId, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: tt.savedScOpId, + LastBsOpId: tt.savedBsOpId, + }, } // Verify counters restored from "database" @@ -246,8 +254,10 @@ func TestSessionOpIdResume(t *testing.T) { // are thread-safe with mutex protection. func TestSessionOpIdConcurrency(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + LastScOpId: 0, + }, } numGoroutines := 10 diff --git a/KC-Core/pkg/bssci/status_attach_remediation_test.go b/KC-Core/pkg/bssci/status_attach_remediation_test.go index d6e19b6..800578b 100644 --- a/KC-Core/pkg/bssci/status_attach_remediation_test.go +++ b/KC-Core/pkg/bssci/status_attach_remediation_test.go @@ -42,13 +42,15 @@ func TestSubpacketsNormalization(t *testing.T) { mockConn := &remedTestConn{} session := &bssci.Session{ - ID: "test-subpackets-session", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - BsOpId: 0, - ScOpId: 0, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-subpackets-session", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + HandshakeComplete: true, + BsOpId: 0, + ScOpId: 0, + }, + Conn: mockConn, } // Create attach message with subpackets as Object (map) per BSSCI §5.6.1 @@ -138,13 +140,15 @@ func TestEqSnrOptionalHandling(t *testing.T) { mockConn := &remedTestConn{} session := &bssci.Session{ - ID: "test-eqsnr-session", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - BsOpId: 0, - ScOpId: 0, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-eqsnr-session", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + HandshakeComplete: true, + BsOpId: 0, + ScOpId: 0, + }, + Conn: mockConn, } msg := &bssci.Message{ @@ -196,13 +200,15 @@ func TestAttachMessagePersistence(t *testing.T) { mockConn := &remedTestConn{} session := &bssci.Session{ - ID: "test-attach-persist-session", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - BsOpId: 0, - ScOpId: 0, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-attach-persist-session", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + HandshakeComplete: true, + BsOpId: 0, + ScOpId: 0, + }, + Conn: mockConn, } // Create attach message with all required fields @@ -244,14 +250,16 @@ func TestStatusHistoryPersistence(t *testing.T) { mockConn := &remedTestConn{} session := &bssci.Session{ - ID: "test-status-persist-session", - BaseStationEUI: bssci.TestBsEui04, - DbSessionID: 123, - Conn: mockConn, - Encoding: "msgpack", - HandshakeComplete: true, - BsOpId: 0, - ScOpId: 0, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-status-persist-session", + BaseStationEUI: bssci.TestBsEui04, + DbSessionID: 123, + Encoding: "msgpack", + HandshakeComplete: true, + BsOpId: 0, + ScOpId: 0, + }, + Conn: mockConn, } // Create status request diff --git a/KC-Core/pkg/bssci/status_handlers.go b/KC-Core/pkg/bssci/status_handlers.go index 73bc14e..f4fa514 100644 --- a/KC-Core/pkg/bssci/status_handlers.go +++ b/KC-Core/pkg/bssci/status_handlers.go @@ -250,27 +250,24 @@ func (s *Server) handleStatusResponse(_ *Server, session *Session, msg *Message, } } - // Send statusCmp to complete three-way handshake + // The service center completes its own SC-initiated status operation + // (BSSCI §3.5): after the base station's statusRsp, the SC sends statusCmp + // and finalizes the pending operation. Because the SC sends the completion + // itself, a spec-compliant base station never returns statusCmp, so the + // pending row must be removed here or it leaks. complete := map[string]interface{}{ "command": mioty.CmdStatusComplete, "opId": msg.OpId, } - return s.sendMessage(session, complete) -} - -// handleStatusComplete handles statusCmp from base station -func (s *Server) handleStatusComplete(_ *Server, session *Session, msg *Message, _ map[string]interface{}) error { - // Remove completed operation from pending operations for MIOTY session session resume + if err := s.sendMessage(session, complete); err != nil { + return err + } + // Finalize only after the completion write succeeded; a failed remove + // preserves the pending operation for recovery. if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingStatusOperation, - "error", err, - "opId", msg.OpId, - "sessionId", session.DbSessionID) + s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationFromDatabase, + "error", err, "opId", msg.OpId) } - - s.logger.DebugContext(s.sessionContext(session), LogBSSCIStatusOperationCompleted, - "bsEui", session.BaseStationEUI, - "opId", msg.OpId) return nil } @@ -343,10 +340,16 @@ func (s *Server) SendStatusRequest(session interface{}) (int64, error) { "bsEui", sess.BaseStationEUI, "opId", opId) - // Persist pending operation for MIOTY session session resume + // The recovery record must be durable before the operation goes on the + // wire: an SC operation whose pending row was never persisted cannot be + // reissued on resume. A persistence failure rolls the operation ID back + // and aborts the send. if err := s.persistPendingOperation(sess, opId, mioty.CmdStatus, statusRequest, nil, nil); err != nil { s.logger.ErrorContext(s.sessionContext(sess), LogBSSCIFailedToPersistPendingStatusOperation, "error", err) - // Continue anyway - persistence failure shouldn't block operation + sess.mu.Lock() + sess.LastScOpId++ + sess.mu.Unlock() + return 0, fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistPendingStatusOperation), err) } // Send message with rollback guard (BSSCI §5.2) diff --git a/KC-Core/pkg/bssci/status_handlers_test.go b/KC-Core/pkg/bssci/status_handlers_test.go index 9a9ec7b..f5575ba 100644 --- a/KC-Core/pkg/bssci/status_handlers_test.go +++ b/KC-Core/pkg/bssci/status_handlers_test.go @@ -121,10 +121,12 @@ func TestStatusResponseSendsCompletion(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-status-session", - BaseStationEUI: 0x1122334455667788, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-status-session", + BaseStationEUI: 0x1122334455667788, + Encoding: "msgpack", + }, + Conn: mockConn, } // Create statusRsp message with all mandatory fields @@ -207,10 +209,12 @@ func TestStatusMandatoryFieldValidation(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-validation-session", - BaseStationEUI: 0x1122334455667788, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-validation-session", + BaseStationEUI: 0x1122334455667788, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -246,10 +250,12 @@ func TestStatusOptionalFieldHandling(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-optional-session", - BaseStationEUI: 0x1122334455667788, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-optional-session", + BaseStationEUI: 0x1122334455667788, + Encoding: "msgpack", + }, + Conn: mockConn, } // statusRsp with only mandatory fields (no optional fields) @@ -281,10 +287,12 @@ func TestStatusWithAllFields(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-all-fields-session", - BaseStationEUI: 0x1122334455667788, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-all-fields-session", + BaseStationEUI: 0x1122334455667788, + Encoding: "msgpack", + }, + Conn: mockConn, } // statusRsp with all optional fields @@ -435,11 +443,14 @@ func TestStatusResponseRespectsTenantIsolation(t *testing.T) { // Session with ResolvedTenantID = 42 (different from server's default tenant 1) session := &bssci.Session{ - ID: "test-tenant-isolation", - BaseStationEUI: 0x1122334455667788, - Conn: mockConn, - Encoding: "msgpack", - ResolvedTenantID: 42, // This should be used instead of server's tenantID (1) + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-tenant-isolation", + BaseStationEUI: 0x1122334455667788, + Encoding: "msgpack", + ResolvedTenantID: 42, + }, + Conn: mockConn, + // This should be used instead of server's tenantID (1) } // Create statusRsp with mandatory fields @@ -624,10 +635,12 @@ func TestStatusResponsePersistsGeoLocation(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geolocation", - BaseStationEUI: 0x1122334455667788, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geolocation", + BaseStationEUI: 0x1122334455667788, + Encoding: "msgpack", + }, + Conn: mockConn, } // Create statusRsp with mandatory fields and geoLocation @@ -970,10 +983,12 @@ func TestStatusGetByEUIFailure(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-db-error-getbyeui", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-db-error-getbyeui", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1017,10 +1032,12 @@ func TestStatusUpdateFailure(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-update-error", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-update-error", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1063,10 +1080,12 @@ func TestStatusHistoryPersistenceFailure(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-history-error", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-history-error", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1114,109 +1133,6 @@ func TestStatusHistoryPersistenceFailure(t *testing.T) { // ========== StatusComplete Error Path Tests ========== -// mockStatusService is a mock for status service that can inject RemovePendingOperation errors -type mockStatusService struct { - removePendingOpError error - removePendingOpCalls int -} - -func (m *mockStatusService) RemovePendingOperation(_ context.Context, _ *bssci.Session, _ int64) error { - m.removePendingOpCalls++ - return m.removePendingOpError -} - -func (m *mockStatusService) RecordPendingOperation(_ context.Context, _ *bssci.Session, _ int64, _ *bssci.PendingOperation, _ int64) error { - return nil -} - -func (m *mockStatusService) GetPendingOperation(_ *bssci.Session, _ int64) (*bssci.PendingOperation, error) { - return nil, nil -} - -func (m *mockStatusService) CleanupPendingOp(_ *bssci.Session, _ int64) { -} - -func (m *mockStatusService) ExtractQueueMetadata(_ *bssci.Session, _ int64) (uint64, int64, string) { - return 0, 0, "" -} - -// TestStatusCompletePendingOperationFailure verifies BSSCI §3.5.3 lines 916-930: -// removePendingOperation errors should be logged but NOT bubble up as handler errors. -// The statusCmp handler should complete successfully even if cleanup fails. -// -// Lines validated: status_handlers.go:259-273 -func TestStatusCompletePendingOperationFailure(t *testing.T) { - logger := logger.NewNop() - - sessionSvc, downlinkSvc, _, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := bssciservices.CreateTestServices(logger, nil) - - // Replace statusSvc with our mock that fails on RemovePendingOperation - mockStatusSvc := &mockStatusService{ - removePendingOpError: assert.AnError, - } - - server := bssci.NewTestServer(logger, mockStorage, nil, 1, - sessionSvc, downlinkSvc, mockStatusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) - - mockConn := &statusMockConn{} - session := &bssci.Session{ - ID: "test-statuscmp-cleanup-error", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - DbSessionID: 42, - } - - statusCmpMsg := &bssci.Message{ - Command: "statusCmp", - OpId: -5, - } - - err := server.CallHandleStatusComplete(session, statusCmpMsg, nil) - require.NoError(t, err, "Handler must not return error even if RemovePendingOperation fails") - - // Verify RemovePendingOperation was attempted - assert.Equal(t, 1, mockStatusSvc.removePendingOpCalls, "RemovePendingOperation should have been called once") -} - -// TestStatusCompleteIdempotency verifies BSSCI §3.5.3: -// Receiving duplicate statusCmp messages (e.g., due to retry) should be handled gracefully. -// Multiple calls should not cause errors even if pending operation was already removed. -// -// Lines validated: status_handlers.go:259-273 -func TestStatusCompleteIdempotency(t *testing.T) { - logger := logger.NewNop() - - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := bssciservices.CreateTestServices(logger, nil) - server := bssci.NewTestServer(logger, mockStorage, nil, 1, - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) - - mockConn := &statusMockConn{} - session := &bssci.Session{ - ID: "test-statuscmp-idempotent", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - DbSessionID: 43, - } - - statusCmpMsg := &bssci.Message{ - Command: "statusCmp", - OpId: -6, - } - - // First call - should succeed - err := server.CallHandleStatusComplete(session, statusCmpMsg, nil) - require.NoError(t, err, "First statusCmp should succeed") - - // Second call with same opId - should also succeed (idempotent) - err = server.CallHandleStatusComplete(session, statusCmpMsg, nil) - require.NoError(t, err, "Duplicate statusCmp should be handled gracefully") - - // No messages should be sent in response to statusCmp (it's the final step) - assert.Empty(t, mockConn.sentMessages, "No messages should be sent in response to statusCmp") -} - // ========== GeoLocation Edge Case Tests ========== // TestStatusGeoLocationStringFormat verifies BSSCI §3.5.2 lines 820-844: @@ -1235,10 +1151,12 @@ func TestStatusGeoLocationStringFormat(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-string", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-string", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1282,10 +1200,12 @@ func TestStatusGeoLocationPartialArray(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-partial", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-partial", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1329,10 +1249,12 @@ func TestStatusGeoLocationNonNumericArray(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-nonnumeric", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-nonnumeric", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1376,10 +1298,12 @@ func TestStatusGeoLocationNestedObject(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-nested", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-nested", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1437,10 +1361,12 @@ func TestStatusMissingCodeFieldShortCircuits(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-missing-code", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-missing-code", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } // statusRsp missing mandatory 'code' field @@ -1487,10 +1413,12 @@ func TestStatusInvalidMessageTypeShortCircuits(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-invalid-message-type", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-invalid-message-type", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } // statusRsp with non-string 'message' field @@ -1537,10 +1465,12 @@ func TestStatusInvalidTimeTypeShortCircuits(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-invalid-time-type", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-invalid-time-type", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } // statusRsp with string 'time' field (should be int64) @@ -1612,11 +1542,13 @@ func TestStatusTenantIsolationUnderUpdateFailure(t *testing.T) { // Session with resolved tenant ID = 99 (different from server default) session := &bssci.Session{ - ID: "test-tenant-isolation-update-fail", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - ResolvedTenantID: 99, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-tenant-isolation-update-fail", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + ResolvedTenantID: 99, + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1681,11 +1613,13 @@ func TestStatusTenantIsolationUnderHistoryFailure(t *testing.T) { // Session with resolved tenant ID = 88 (different from server default) session := &bssci.Session{ - ID: "test-tenant-isolation-history-fail", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - ResolvedTenantID: 88, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-tenant-isolation-history-fail", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + ResolvedTenantID: 88, + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1740,10 +1674,12 @@ func TestStatusHistoryPersistenceSuccess(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-history-success", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-history-success", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -1822,10 +1758,12 @@ func TestStatusHistorySkippedWhenStorageNil(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-history-nil-storage", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-history-nil-storage", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -2035,11 +1973,14 @@ func TestStatusResponseConcurrentUpdates(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-concurrent-status", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", - ResolvedTenantID: sessionTenantID, // Use non-default tenant to catch regressions + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-concurrent-status", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + ResolvedTenantID: sessionTenantID, + }, + Conn: mockConn, + // Use non-default tenant to catch regressions } numGoroutines := 10 @@ -2145,10 +2086,12 @@ func TestStatusHandler_ValidGeoLocation_SetsGPSSource(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-gps-source", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-gps-source", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } beforeCall := time.Now() @@ -2224,10 +2167,12 @@ func TestStatusHandler_OutOfRangeLatLon_SkipsAll(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-out-of-range", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-out-of-range", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -2274,10 +2219,12 @@ func TestStatusHandler_ZeroGeoLocation_SkipsAll(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-zero", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-zero", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ @@ -2346,10 +2293,12 @@ func TestStatusHandler_PartialTriple_SkipsAll(t *testing.T) { mockConn := &statusMockConn{} session := &bssci.Session{ - ID: "test-geo-partial-triple", - BaseStationEUI: bssci.TestBsEui04, - Conn: mockConn, - Encoding: "msgpack", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-geo-partial-triple", + BaseStationEUI: bssci.TestBsEui04, + Encoding: "msgpack", + }, + Conn: mockConn, } statusMsg := &bssci.Message{ diff --git a/KC-Core/pkg/bssci/sublayer_test.go b/KC-Core/pkg/bssci/sublayer_test.go index 78828da..0f47a0f 100644 --- a/KC-Core/pkg/bssci/sublayer_test.go +++ b/KC-Core/pkg/bssci/sublayer_test.go @@ -56,11 +56,13 @@ func TestBSSCI_4_01_unsupported_sublayer(t *testing.T) { queueSerializer, auditLogger, tenantResolver) session := &bssci.Session{ - ID: "test-session", - BaseStationEUI: bssci.TestBsEui01, - Conn: mockConn, - Encoding: "json", - HandshakeComplete: true, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session", + BaseStationEUI: bssci.TestBsEui01, + Encoding: "json", + HandshakeComplete: true, + }, + Conn: mockConn, } server.RegisterSession(session) diff --git a/KC-Core/pkg/bssci/tenant_context_test.go b/KC-Core/pkg/bssci/tenant_context_test.go index c73d594..627ff45 100644 --- a/KC-Core/pkg/bssci/tenant_context_test.go +++ b/KC-Core/pkg/bssci/tenant_context_test.go @@ -16,8 +16,8 @@ import ( // stubSessionService is a no-op session service for smoke tests type stubSessionService struct{} -func (s *stubSessionService) HandleResume(_ context.Context, _ *Session, _ []byte, _, _ *int64, _ uint64) (*Session, error) { - return nil, nil // No-op for smoke tests +func (s *stubSessionService) HandleResume(_ context.Context, _ *Session, _ []byte, _, _ *int64, _ uint64) ResumeOutcome { + return ResumeOutcome{Disposition: ResumeNoMatch} // No-op for smoke tests } func (s *stubSessionService) PersistSession(_ context.Context, _ *Session, _ *basestation.BaseStation, _ bool, _ json.RawMessage) error { diff --git a/KC-Core/pkg/bssci/tenant_isolation_test.go b/KC-Core/pkg/bssci/tenant_isolation_test.go index 76579a6..5700a6e 100644 --- a/KC-Core/pkg/bssci/tenant_isolation_test.go +++ b/KC-Core/pkg/bssci/tenant_isolation_test.go @@ -19,26 +19,43 @@ func TestResolvedTenantUsesServerTenant(t *testing.T) { expected: 1, }, { - name: "unresolved_session_uses_server_tenant", - session: &Session{ResolvedTenantID: 0}, + name: "unresolved_session_uses_server_tenant", + session: &Session{ + ProtocolSessionState: ProtocolSessionState{ + ResolvedTenantID: 0, + }, + }, serverTenantID: 1, expected: 1, }, { - name: "resolved_session_uses_own_tenant", - session: &Session{ResolvedTenantID: 42}, + name: "resolved_session_uses_own_tenant", + session: &Session{ + ProtocolSessionState: ProtocolSessionState{ + ResolvedTenantID: 42, + }, + }, serverTenantID: 1, expected: 42, }, { - name: "cert_resolved_tenant_overrides_server_default", - session: &Session{ResolvedTenantID: 99}, + name: "cert_resolved_tenant_overrides_server_default", + session: &Session{ + ProtocolSessionState: ProtocolSessionState{ + ResolvedTenantID: 99, + }, + }, serverTenantID: 1, expected: 99, }, { - name: "fallback_when_tenant_not_resolved", - session: &Session{ResolvedTenantID: 0, BaseStationEUI: 123456}, + name: "fallback_when_tenant_not_resolved", + session: &Session{ + ProtocolSessionState: ProtocolSessionState{ + ResolvedTenantID: 0, + BaseStationEUI: 123456, + }, + }, serverTenantID: 5, expected: 5, }, @@ -75,8 +92,12 @@ func TestResolvedTenantServerTenantNotDefaultTenant(t *testing.T) { description string }{ { - name: "community_mode_tenant_mismatch", - session: &Session{ResolvedTenantID: 0}, + name: "community_mode_tenant_mismatch", + session: &Session{ + ProtocolSessionState: ProtocolSessionState{ + ResolvedTenantID: 0, + }, + }, serverTenantID: 1, defaultTenantID: 100, // Wrong fallback expectedTenantID: 1, // Should use serverTenantID @@ -91,8 +112,13 @@ func TestResolvedTenantServerTenantNotDefaultTenant(t *testing.T) { description: "Multi-tenant: each server instance has dedicated tenantID", }, { - name: "cert_resolution_pending", - session: &Session{ResolvedTenantID: 0, BaseStationEUI: TestEpEui01}, + name: "cert_resolution_pending", + session: &Session{ + ProtocolSessionState: ProtocolSessionState{ + ResolvedTenantID: 0, + BaseStationEUI: TestEpEui01, + }, + }, serverTenantID: 42, defaultTenantID: 1, expectedTenantID: 42, diff --git a/KC-Core/pkg/bssci/test_helpers_test.go b/KC-Core/pkg/bssci/test_helpers_test.go index d77ba76..518bd51 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -95,11 +95,6 @@ func (s *Server) CallHandleDLDataResult(session *Session, msg *Message, data map return s.handleDLDataResult(s, session, msg, data) } -// CallHandleDLRXStatusQueryComplete exposes the unexported handleDLRXStatusQueryComplete method for testing. -func (s *Server) CallHandleDLRXStatusQueryComplete(session *Session, msg *Message, data map[string]interface{}) error { - return s.handleDLRXStatusQueryComplete(s, session, msg, data) -} - // RegisterHandlers exposes the unexported registerHandlers method for testing. func (s *Server) RegisterHandlers() { s.registerHandlers() @@ -177,11 +172,6 @@ func (s *Server) CallHandleStatusResponse(session *Session, msg *Message, data m // return nil // } -// CallHandleStatusComplete exposes the unexported handleStatusComplete method for testing. -func (s *Server) CallHandleStatusComplete(session *Session, msg *Message, data map[string]interface{}) error { - return s.handleStatusComplete(s, session, msg, data) -} - // CallHandleAttachPropagateResponse exposes the unexported handleAttachPropagateResponse method for testing. func (s *Server) CallHandleAttachPropagateResponse(session *Session, msg *Message, data map[string]interface{}) error { return s.handleAttachPropagateResponse(s, session, msg, data) @@ -367,7 +357,7 @@ func newMockSessionService() *mockSessionService { } } -func (m *mockSessionService) HandleResume(_ context.Context, _ *Session, bsUUID []byte, bsOpId, scOpId *int64, _ uint64) (*Session, error) { +func (m *mockSessionService) HandleResume(_ context.Context, _ *Session, bsUUID []byte, bsOpId, scOpId *int64, _ uint64) ResumeOutcome { m.mu.RLock() defer m.mu.RUnlock() @@ -376,16 +366,16 @@ func (m *mockSessionService) HandleResume(_ context.Context, _ *Session, bsUUID if existingSession, exists := m.sessionsByUUID[key]; exists { // Mirror production constraint semantics: absent constraints pass; // required BS operation ID beyond known state or claimed SC - // operation ID beyond issued state rejects + // operation ID beyond issued state is an inconsistent resume if bsOpId != nil && *bsOpId > existingSession.LastBsOpId { - return nil, nil + return ResumeOutcome{Disposition: ResumeInconsistent, Previous: existingSession, Err: ErrResumeCounterMismatch} } if scOpId != nil && *scOpId < existingSession.LastScOpId { - return nil, nil + return ResumeOutcome{Disposition: ResumeInconsistent, Previous: existingSession, Err: ErrResumeCounterMismatch} } - return existingSession, nil + return ResumeOutcome{Disposition: ResumeCompatible, Previous: existingSession} } - return nil, nil // No valid resume + return ResumeOutcome{Disposition: ResumeNoMatch} // No valid resume } func (m *mockSessionService) PersistSession(_ context.Context, session *Session, _ *basestation.BaseStation, isResume bool, connectInfo json.RawMessage) error { diff --git a/KC-Core/pkg/bssci/uldata_deduplication_integration_test.go b/KC-Core/pkg/bssci/uldata_deduplication_integration_test.go index e36d4a9..405bb19 100644 --- a/KC-Core/pkg/bssci/uldata_deduplication_integration_test.go +++ b/KC-Core/pkg/bssci/uldata_deduplication_integration_test.go @@ -440,13 +440,15 @@ func createTestEndpoint(epEui uint64, tenantID int64) *models.EndPoint { // createTestSession creates a test BSSCI session with given BS EUI func createTestSession(bsEui uint64, tenantID int64, conn net.Conn) *bssci.Session { return &bssci.Session{ - ID: "test-ul-dedup-session", - BaseStationEUI: bsEui, - Conn: conn, - Encoding: "msgpack", - HandshakeComplete: true, - ResolvedTenantID: tenantID, - DbSessionID: 1, + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-ul-dedup-session", + BaseStationEUI: bsEui, + Encoding: "msgpack", + HandshakeComplete: true, + ResolvedTenantID: tenantID, + DbSessionID: 1, + }, + Conn: conn, } } diff --git a/KC-Core/pkg/bssci/version_interop_test.go b/KC-Core/pkg/bssci/version_interop_test.go index a143930..83c83a4 100644 --- a/KC-Core/pkg/bssci/version_interop_test.go +++ b/KC-Core/pkg/bssci/version_interop_test.go @@ -353,3 +353,100 @@ func TestBSSCIVersionInterop_OliverScenario(t *testing.T) { pingRsp := h.readFrame() assert.Equal(t, mioty.CmdPingResponse, frameCommand(pingRsp)) } + +// TestBSSCIVersionInterop_StateMachineEdges drives illegal handshake +// sequences over real framed traffic. Each must produce an error frame (never +// a normal response) and enter the error/errorAck sequence (§5.17). +func TestBSSCIVersionInterop_StateMachineEdges(t *testing.T) { + t.Run("InboundConRspRejected", func(t *testing.T) { + // conRsp is SC-initiated (SCtoBS); a base station sending it before + // the handshake is a protocol-ordering violation + h := startInteropServer(t, EncodingJSON) + h.writeFrame(map[string]interface{}{ + "command": mioty.CmdConnectResponse, + "opId": int64(0), + "version": mioty.MIOTYProtocolVersion, + }) + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame), + "an inbound conRsp must be rejected, not handled") + }) + + t.Run("ConCmpBeforeCon", func(t *testing.T) { + // conCmp before con completes an operation that never started + h := startInteropServer(t, EncodingJSON) + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame)) + }) + + t.Run("PreHandshakeCommandRejected", func(t *testing.T) { + // a normal operation before the connect handshake completes + h := startInteropServer(t, EncodingJSON) + h.writeFrame(map[string]interface{}{"command": mioty.CmdPing, "opId": int64(1)}) + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame), + "a pre-handshake command must be rejected with an error, entering the errorAck sequence") + }) + + t.Run("DuplicateCon", func(t *testing.T) { + // a second con while the first connect operation is in flight + h := startInteropServer(t, EncodingJSON) + h.writeFrame(connectPayload("1.0.0", uint64(0x70B3D59CD00009E6))) + conRsp := h.readFrame() + require.Equal(t, mioty.CmdConnectResponse, frameCommand(conRsp)) + + // conRsp sent, awaiting conCmp - a duplicate con is out of order + h.writeFrame(connectPayload("1.0.0", uint64(0x70B3D59CD00009E6))) + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame), + "a duplicate con while awaiting conCmp must be rejected") + }) +} + +// TestBSSCIVersionInterop_ErrorAlwaysAcksNeverCompletes verifies BSSCI §5.17: +// an inbound error on an active session is answered with errorAck, never with +// an operation-specific completion, and the operation type is never guessed. +func TestBSSCIVersionInterop_ErrorAlwaysAcksNeverCompletes(t *testing.T) { + h := startInteropServer(t, EncodingJSON) + + h.writeFrame(connectPayload("1.0.0", uint64(0x70B3D59CD00009E6))) + require.Equal(t, mioty.CmdConnectResponse, frameCommand(h.readFrame())) + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + + // An inbound error for a negative (SC-initiated) opId with no tracked + // pending operation must be acknowledged with errorAck - the old behavior + // guessed detach-propagate and answered with detPrpCmp. + h.writeFrame(map[string]interface{}{ + "command": mioty.CmdError, + "opId": int64(-1), + "code": int64(POSIX_EPROTO), + "message": "operation failed", + }) + ack := h.readFrame() + require.Equal(t, mioty.CmdErrorAck, frameCommand(ack), + "an inbound error must be answered with errorAck, never a *Cmp") +} + +// TestBSSCIVersionInterop_InboundServiceCenterCommandRejected verifies BSSCI +// direction enforcement: a base station sending a command the service center +// itself initiates (statusCmp, dlDataQueCmp) is rejected before dispatch. +func TestBSSCIVersionInterop_InboundServiceCenterCommandRejected(t *testing.T) { + for _, cmd := range []string{mioty.CmdStatusComplete, mioty.CmdDLDataQueueComplete, mioty.CmdStatus} { + t.Run(cmd, func(t *testing.T) { + h := startInteropServer(t, EncodingJSON) + h.writeFrame(connectPayload("1.0.0", uint64(0x70B3D59CD00009E6))) + require.Equal(t, mioty.CmdConnectResponse, frameCommand(h.readFrame())) + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + + // The base station illegally sends an SC-initiated command + h.writeFrame(map[string]interface{}{"command": cmd, "opId": int64(-3)}) + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame), + "an inbound SC-initiated command must be rejected with an error") + code, err := coerceInt64(errFrame["code"]) + require.NoError(t, err) + assert.Equal(t, int64(POSIX_EPROTO), code) + }) + } +} diff --git a/KC-Core/pkg/bssci/vm_handlers_integration_test.go b/KC-Core/pkg/bssci/vm_handlers_integration_test.go index 9c91760..33b69b8 100644 --- a/KC-Core/pkg/bssci/vm_handlers_integration_test.go +++ b/KC-Core/pkg/bssci/vm_handlers_integration_test.go @@ -30,11 +30,13 @@ func setupVMTestServer(_ *testing.T) (*Server, *Session, *bsscitest.TestConn) { // Create session with unique ID sessionID := fmt.Sprintf("test-session-%d", time.Now().UnixNano()) session := &Session{ - ID: sessionID, - BaseStationEUI: 0x1122334455667788, - ResolvedTenantID: 123, - Conn: conn, - DbSessionID: 456, + ProtocolSessionState: ProtocolSessionState{ + ID: sessionID, + BaseStationEUI: 0x1122334455667788, + ResolvedTenantID: 123, + DbSessionID: 456, + }, + Conn: conn, } // Add session to server (sessions map uses session.ID, not BaseStationEUI) diff --git a/KC-Core/pkg/bssci/vm_handlers_test.go b/KC-Core/pkg/bssci/vm_handlers_test.go index 5ecef2d..c938b6f 100644 --- a/KC-Core/pkg/bssci/vm_handlers_test.go +++ b/KC-Core/pkg/bssci/vm_handlers_test.go @@ -18,10 +18,12 @@ import ( func TestVMHandlerSignatures(t *testing.T) { server := createTestServerWithSession() session := &Session{ - ID: "test-session-vm", - BaseStationEUI: TestEpEui01, - Name: "test-bs", - DbSessionID: 1, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-vm", + BaseStationEUI: TestEpEui01, + DbSessionID: 1, + }, + Name: "test-bs", } msg := &Message{ Command: mioty.CmdVMActivate, @@ -138,10 +140,12 @@ func TestVMCommunityEditionStubs(t *testing.T) { server := createTestServerWithSession() conn := &testutil.TestConn{Encoding: "msgpack"} session := &Session{ - BaseStationEUI: TestEpEui01, - Name: "test-bs", - DbSessionID: 1, - Conn: conn, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + DbSessionID: 1, + }, + Name: "test-bs", + Conn: conn, } msg := &Message{ Command: mioty.CmdVMDLData, @@ -230,11 +234,13 @@ func TestVMSendMethods(t *testing.T) { // Register test session session := &Session{ - ID: "test-session-vm", - BaseStationEUI: TestEpEui01, - Name: "test-bs-vm", - DbSessionID: 1, - LastScOpId: -1, + ProtocolSessionState: ProtocolSessionState{ + ID: "test-session-vm", + BaseStationEUI: TestEpEui01, + DbSessionID: 1, + LastScOpId: -1, + }, + Name: "test-bs-vm", } server.RegisterSession(session) @@ -267,10 +273,12 @@ func TestVMSendMethods(t *testing.T) { func TestVMActiveTypesTracking(t *testing.T) { server := createTestServerWithSession() session := &Session{ - BaseStationEUI: TestEpEui01, - Name: "test-bs", - DbSessionID: 1, - ActiveVMTypes: make(map[uint64][]uint8), + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + DbSessionID: 1, + }, + Name: "test-bs", + ActiveVMTypes: make(map[uint64][]uint8), } msg := &Message{ Command: mioty.CmdVMActivateResponse, diff --git a/KC-Core/pkg/bssci/vm_prefix_guard_test.go b/KC-Core/pkg/bssci/vm_prefix_guard_test.go index 746cdab..dbf55b1 100644 --- a/KC-Core/pkg/bssci/vm_prefix_guard_test.go +++ b/KC-Core/pkg/bssci/vm_prefix_guard_test.go @@ -66,9 +66,11 @@ func TestVMPrefixGuardAllowsRegisteredHandlers(t *testing.T) { // Create test connection conn := &testConn{} session := &Session{ - HandshakeComplete: true, - Conn: conn, - Encoding: EncodingJSON, + ProtocolSessionState: ProtocolSessionState{ + HandshakeComplete: true, + Encoding: EncodingJSON, + }, + Conn: conn, } // Register handler that flips boolean diff --git a/KC-Core/pkg/config/constants.go b/KC-Core/pkg/config/constants.go index 1ac917f..cc1b2bf 100644 --- a/KC-Core/pkg/config/constants.go +++ b/KC-Core/pkg/config/constants.go @@ -389,6 +389,9 @@ const ( // DefaultProtocolAckTimeout is the acknowledgement timeout in milliseconds. DefaultProtocolAckTimeout = 5000 + // DefaultProtocolConnectionEstablishmentTimeout bounds a fresh connection before its con arrives, in milliseconds. + DefaultProtocolConnectionEstablishmentTimeout = 30000 + // DefaultProtocolDuplicateWindow is the duplicate detection window in seconds. DefaultProtocolDuplicateWindow = 300 diff --git a/KC-Core/pkg/config/loader.go b/KC-Core/pkg/config/loader.go index 7dd83f4..e589315 100644 --- a/KC-Core/pkg/config/loader.go +++ b/KC-Core/pkg/config/loader.go @@ -166,6 +166,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("protocol.sc_model", DefaultProtocolSCModel) v.SetDefault("protocol.max_retransmissions", DefaultProtocolMaxRetransmissions) v.SetDefault("protocol.ack_timeout", DefaultProtocolAckTimeout) + v.SetDefault("protocol.connection_establishment_timeout", DefaultProtocolConnectionEstablishmentTimeout) v.SetDefault("protocol.duplicate_window", DefaultProtocolDuplicateWindow) v.SetDefault("protocol.bsci_certificate_poll_interval", DefaultProtocolCertificatePollInterval) diff --git a/KC-Core/pkg/config/protocol.go b/KC-Core/pkg/config/protocol.go index 7834a20..063eaaf 100644 --- a/KC-Core/pkg/config/protocol.go +++ b/KC-Core/pkg/config/protocol.go @@ -26,10 +26,11 @@ type ProtocolConfig struct { SCALogStatusOperations bool `mapstructure:"scaci_log_status_operations"` // Log Status operations to audit trail (default: true) // Protocol Parameters - MaxRetransmissions int `mapstructure:"max_retransmissions"` - AckTimeout int `mapstructure:"ack_timeout"` // milliseconds - DuplicateWindow int `mapstructure:"duplicate_window"` // seconds - MessageEncoding string `mapstructure:"message_encoding"` // json, msgpack + MaxRetransmissions int `mapstructure:"max_retransmissions"` + AckTimeout int `mapstructure:"ack_timeout"` // milliseconds + ConnectionEstablishmentTimeout int `mapstructure:"connection_establishment_timeout"` // milliseconds + DuplicateWindow int `mapstructure:"duplicate_window"` // seconds + MessageEncoding string `mapstructure:"message_encoding"` // json, msgpack // BSCICertificatePollInterval is the base station certificate change poll interval BSCICertificatePollInterval time.Duration `mapstructure:"bsci_certificate_poll_interval"` diff --git a/KC-Core/pkg/config/service_center.go b/KC-Core/pkg/config/service_center.go index 7a39d83..632e0e1 100644 --- a/KC-Core/pkg/config/service_center.go +++ b/KC-Core/pkg/config/service_center.go @@ -32,6 +32,10 @@ func ValidateServiceCenterConfig(cfg *ProtocolConfig) error { return fmt.Errorf("protocol.ack_timeout must be positive milliseconds, got %d", cfg.AckTimeout) } + if cfg.ConnectionEstablishmentTimeout <= 0 { + return fmt.Errorf("protocol.connection_establishment_timeout must be positive milliseconds, got %d", cfg.ConnectionEstablishmentTimeout) + } + if cfg.DuplicateWindow <= 0 { return fmt.Errorf("protocol.duplicate_window must be positive seconds, got %d", cfg.DuplicateWindow) } diff --git a/KC-Core/pkg/config/service_center_test.go b/KC-Core/pkg/config/service_center_test.go index 0a3389f..cd0eede 100644 --- a/KC-Core/pkg/config/service_center_test.go +++ b/KC-Core/pkg/config/service_center_test.go @@ -336,12 +336,13 @@ func TestValidateServiceCenterConfig_TLSDisabled(t *testing.T) { func TestValidateServiceCenterConfig_Valid(t *testing.T) { cfg := &ProtocolConfig{ - BSCIHost: "bssci.mioty.local", - BSCIPort: 5000, - BSCITLS: TLSConfig{Enabled: true}, - AckTimeout: DefaultProtocolAckTimeout, - DuplicateWindow: DefaultProtocolDuplicateWindow, - BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, + BSCIHost: "bssci.mioty.local", + BSCIPort: 5000, + BSCITLS: TLSConfig{Enabled: true}, + AckTimeout: DefaultProtocolAckTimeout, + ConnectionEstablishmentTimeout: DefaultProtocolConnectionEstablishmentTimeout, + DuplicateWindow: DefaultProtocolDuplicateWindow, + BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, } err := ValidateServiceCenterConfig(cfg) @@ -353,12 +354,13 @@ func TestValidateServiceCenterConfig_Valid(t *testing.T) { func TestValidateServiceCenterConfig_TimingBounds(t *testing.T) { base := func() *ProtocolConfig { return &ProtocolConfig{ - BSCIHost: "bssci.mioty.local", - BSCIPort: 5000, - BSCITLS: TLSConfig{Enabled: true}, - AckTimeout: DefaultProtocolAckTimeout, - DuplicateWindow: DefaultProtocolDuplicateWindow, - BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, + BSCIHost: "bssci.mioty.local", + BSCIPort: 5000, + BSCITLS: TLSConfig{Enabled: true}, + AckTimeout: DefaultProtocolAckTimeout, + ConnectionEstablishmentTimeout: DefaultProtocolConnectionEstablishmentTimeout, + DuplicateWindow: DefaultProtocolDuplicateWindow, + BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, } } @@ -368,6 +370,12 @@ func TestValidateServiceCenterConfig_TimingBounds(t *testing.T) { t.Error("zero ack_timeout must fail validation") } + zeroEstablish := base() + zeroEstablish.ConnectionEstablishmentTimeout = 0 + if err := ValidateServiceCenterConfig(zeroEstablish); err == nil { + t.Error("zero connection_establishment_timeout must fail validation") + } + negativeWindow := base() negativeWindow.DuplicateWindow = -1 if err := ValidateServiceCenterConfig(negativeWindow); err == nil { diff --git a/KC-Core/pkg/management/bssci_manager_test.go b/KC-Core/pkg/management/bssci_manager_test.go index f9a6da6..fc2df87 100644 --- a/KC-Core/pkg/management/bssci_manager_test.go +++ b/KC-Core/pkg/management/bssci_manager_test.go @@ -15,13 +15,15 @@ func TestHandleGetConnectedSessionsIncludesVersionFields(t *testing.T) { server := bssci.NewServerForTesting(testLogger) session := &bssci.Session{ - ID: "session-1", - BaseStationEUI: 0x0102030405060708, - ClientVersion: "1.0.0", - NegotiatedVersion: "1.0.0", - Name: "test-bs", - Vendor: "test-vendor", - Model: "test-model", + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "session-1", + BaseStationEUI: 0x0102030405060708, + ClientVersion: "1.0.0", + NegotiatedVersion: "1.0.0", + }, + Name: "test-bs", + Vendor: "test-vendor", + Model: "test-model", } server.AddSessionForTesting(session) diff --git a/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.down.sql b/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.down.sql new file mode 100644 index 0000000..b9cf40d --- /dev/null +++ b/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.down.sql @@ -0,0 +1,7 @@ +-- Migration 000140 down: documented no-op. +-- +-- 000140 deletes pending-operation rows whose owning sessions can never resume. +-- That operational debris cannot be reconstructed, so the downgrade intentionally +-- does nothing (precedent: migration 101). The absence of the purged rows is +-- harmless - they would have been ignored on resume anyway. +SELECT 1; diff --git a/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql b/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql new file mode 100644 index 0000000..09e4461 --- /dev/null +++ b/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql @@ -0,0 +1,52 @@ +-- Migration 000140: purge orphaned BSSCI pending operations by session ownership. +-- +-- Pending operations are reissued on session resume, so a row is only safe to +-- delete when its owning session can never resume: a terminated session, or one +-- explicitly marked non-resumable (can_resume = false). Rows of active and +-- disconnected-resumable sessions are preserved. Deletability is never inferred +-- from operation_type - the historical status-poll leak is reconciled at +-- startup against proven-terminal sessions, not here. +-- +-- Pre/post counts by session status and operation type are emitted as notices +-- for the migration evidence log. The down migration is a documented no-op: +-- deleted operational debris cannot be reconstructed. + +DO $$ +DECLARE + rec RECORD; + deletable BIGINT; +BEGIN + RAISE NOTICE 'KC-MIG-000140: pending operations by session status BEFORE purge:'; + FOR rec IN + SELECT s.status, po.operation_type, COUNT(*) AS n + FROM bssci_pending_operations po + JOIN basestation_sessions s ON s.id = po.basestation_session_id + GROUP BY s.status, po.operation_type + ORDER BY s.status, po.operation_type + LOOP + RAISE NOTICE ' status=% operation_type=% count=%', rec.status, rec.operation_type, rec.n; + END LOOP; + + SELECT COUNT(*) INTO deletable + FROM bssci_pending_operations po + JOIN basestation_sessions s ON s.id = po.basestation_session_id + WHERE s.status = 'terminated' OR s.can_resume = false; + + DELETE FROM bssci_pending_operations po + USING basestation_sessions s + WHERE po.basestation_session_id = s.id + AND (s.status = 'terminated' OR s.can_resume = false); + + RAISE NOTICE 'KC-MIG-000140: purged % pending operation(s) of terminated/non-resumable sessions', deletable; + + RAISE NOTICE 'KC-MIG-000140: pending operations by session status AFTER purge:'; + FOR rec IN + SELECT s.status, po.operation_type, COUNT(*) AS n + FROM bssci_pending_operations po + JOIN basestation_sessions s ON s.id = po.basestation_session_id + GROUP BY s.status, po.operation_type + ORDER BY s.status, po.operation_type + LOOP + RAISE NOTICE ' status=% operation_type=% count=%', rec.status, rec.operation_type, rec.n; + END LOOP; +END $$; diff --git a/KC-DB/storage/mioty/types.go b/KC-DB/storage/mioty/types.go index 410da38..139bfdb 100644 --- a/KC-DB/storage/mioty/types.go +++ b/KC-DB/storage/mioty/types.go @@ -870,13 +870,13 @@ const ( // Ping operations. CmdPing = "ping" // Direction: Bidirectional - initiates ping - CmdPingResponse = "pingRsp" // Direction: BStoSC - response for ping - CmdPingComplete = "pingCmp" // Direction: BStoSC - completion for ping + CmdPingResponse = "pingRsp" // Direction: Bidirectional - response for ping (either side may initiate) + CmdPingComplete = "pingCmp" // Direction: Bidirectional - completion for ping (either side may initiate) // Status operations. - CmdStatus = "status" // Direction: Bidirectional - initiates status request + CmdStatus = "status" // Direction: SCtoBS - Service Center initiates status request (BSSCI 5.5) CmdStatusResponse = "statusRsp" // Direction: BStoSC - response for status - CmdStatusComplete = "statusCmp" // Direction: BStoSC - completion for status + CmdStatusComplete = "statusCmp" // Direction: SCtoBS - SC-sent completion for status // Attach operations. CmdAttach = "att" // Direction: BStoSC - initiates attach @@ -891,12 +891,12 @@ const ( // Attach propagate operations. CmdAttachPropagate = "attPrp" // Direction: SCtoBS - initiates attach propagation CmdAttachPropagateResponse = "attPrpRsp" // Direction: BStoSC - response for attach propagate - CmdAttachPropagateComplete = "attPrpCmp" // Direction: BStoSC - completion for attach propagate + CmdAttachPropagateComplete = "attPrpCmp" // Direction: SCtoBS - SC-sent completion for attach propagate // Detach propagate operations. CmdDetachPropagate = "detPrp" // Direction: SCtoBS - initiates detach propagation CmdDetachPropagateResponse = "detPrpRsp" // Direction: BStoSC - response for detach propagate - CmdDetachPropagateComplete = "detPrpCmp" // Direction: BStoSC - completion for detach propagate + CmdDetachPropagateComplete = "detPrpCmp" // Direction: SCtoBS - SC-sent completion for detach propagate // UL data operations. CmdULData = "ulData" // Direction: BStoSC - initiates uplink data @@ -906,17 +906,17 @@ const ( // UL data transmit operations. CmdULDataTransmit = "ulDataTx" // Direction: SCtoBS - initiates uplink data transmit CmdULDataTransmitResponse = "ulDataTxRsp" // Direction: BStoSC - response for uplink data transmit - CmdULDataTransmitComplete = "ulDataTxCmp" // Direction: BStoSC - completion for uplink data transmit + CmdULDataTransmitComplete = "ulDataTxCmp" // Direction: SCtoBS - SC-sent completion for uplink data transmit // DL data queue operations. CmdDLDataQueue = "dlDataQue" // Direction: SCtoBS - initiates downlink data queue CmdDLDataQueueResponse = "dlDataQueRsp" // Direction: BStoSC - response for downlink queue - CmdDLDataQueueComplete = "dlDataQueCmp" // Direction: BStoSC - completion for downlink queue + CmdDLDataQueueComplete = "dlDataQueCmp" // Direction: SCtoBS - SC-sent completion for downlink queue // DL data revoke operations. CmdDLDataRevoke = "dlDataRev" // Direction: SCtoBS - initiates downlink data revocation CmdDLDataRevokeResponse = "dlDataRevRsp" // Direction: BStoSC - response for downlink revoke - CmdDLDataRevokeComplete = "dlDataRevCmp" // Direction: BStoSC - completion for downlink revoke + CmdDLDataRevokeComplete = "dlDataRevCmp" // Direction: SCtoBS - SC-sent completion for downlink revoke // DL data result operations (BSSCI §3.14). Note: SCACI §3.12 uses different response/complete names. CmdDLDataResult = "dlDataRes" // Direction: BStoSC - initiates downlink data result (shared: BSSCI §3.14 + SCACI §3.12.1) @@ -936,7 +936,7 @@ const ( // DL RX status query operations. CmdDLRxStatusQuery = "dlRxStatQry" // Direction: SCtoBS - initiates downlink RX status query CmdDLRxStatusQueryResponse = "dlRxStatQryRsp" // Direction: BStoSC - response for DL RX status query - CmdDLRxStatusQueryComplete = "dlRxStatQryCmp" // Direction: BStoSC - completion for DL RX status query + CmdDLRxStatusQueryComplete = "dlRxStatQryCmp" // Direction: SCtoBS - SC-sent completion for DL RX status query // Error operations. CmdError = "error" // Direction: Bidirectional - error message From def00b378b4dfd68dbce1b15c0ef9e2f6aab8fa3 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:56 +0200 Subject: [PATCH 04/25] feat(bssci): dlRxStatQry correlation and expiry, certificate issuance hardening Downlink reception status (CHIRP-3543): dlRxStat reports are correlated by the base station they were expected from, and an expiry worker with config-backed timing retires queries that never receive a report. Certificate issuance (CHIRP-3544): issuance is hardened against cross-tenant minting - a certificate can only be produced for a base station the caller's tenant owns. Adds the BSSCI timing keys to the shipped configuration. --- KC-Core/cmd/kilocenter/builders/protocol.go | 4 + KC-Core/config/config.yaml | 4 + KC-Core/internal/grpc/core_service_certs.go | 11 ++- .../internal/services/certificates/service.go | 24 +++++- .../services/certificates/service_test.go | 64 +++++++++++++++ KC-Core/pkg/bssci/constants.go | 9 +++ KC-Core/pkg/bssci/contracts.go | 8 +- KC-Core/pkg/bssci/log_messages.go | 4 + KC-Core/pkg/bssci/server.go | 79 +++++++++++++++++++ KC-Core/pkg/bssci/status_handlers.go | 11 +-- KC-Core/pkg/config/constants.go | 9 +++ KC-Core/pkg/config/loader.go | 4 + KC-Core/pkg/config/protocol.go | 4 + KC-Core/pkg/config/service_center.go | 16 ++++ KC-Core/pkg/config/service_center_test.go | 26 ++++++ KC-Core/pkg/grpc/errors_catalog.go | 6 ++ ...000142_add_dlrx_correlation_index.down.sql | 7 ++ .../000142_add_dlrx_correlation_index.up.sql | 15 ++++ KC-DB/storage/models/integration.go | 2 +- .../postgres/dl_rx_status_correlation_test.go | 76 ++++++++++++++++++ .../postgres/dl_rx_status_repository.go | 10 ++- .../postgres/migration_discovery_test.go | 9 ++- .../components/BsConfigSummary.tsx | 2 +- .../components/ScUrlCopyField.tsx | 2 +- .../components/AddEndPointDialog.tsx | 2 +- config/config.docker.yaml | 5 ++ 26 files changed, 385 insertions(+), 28 deletions(-) create mode 100644 KC-DB/migrations/000142_add_dlrx_correlation_index.down.sql create mode 100644 KC-DB/migrations/000142_add_dlrx_correlation_index.up.sql create mode 100644 KC-DB/storage/postgres/dl_rx_status_correlation_test.go diff --git a/KC-Core/cmd/kilocenter/builders/protocol.go b/KC-Core/cmd/kilocenter/builders/protocol.go index 40e6560..b3edf42 100644 --- a/KC-Core/cmd/kilocenter/builders/protocol.go +++ b/KC-Core/cmd/kilocenter/builders/protocol.go @@ -84,6 +84,10 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol ConnectionEstablishmentTimeout: time.Duration(cfg.Protocol.ConnectionEstablishmentTimeout) * time.Millisecond, DuplicateWindow: time.Duration(cfg.Protocol.DuplicateWindow) * time.Second, CertificatePollInterval: cfg.Protocol.BSCICertificatePollInterval, + StatusRequestInterval: time.Duration(cfg.Protocol.StatusRequestInterval) * time.Second, + StatusRequestInitialDelay: time.Duration(cfg.Protocol.StatusRequestInitialDelay) * time.Second, + DLRXQueryTimeout: time.Duration(cfg.Protocol.DLRXQueryTimeout) * time.Second, + DLRXCleanupInterval: time.Duration(cfg.Protocol.DLRXCleanupInterval) * time.Second, } // Create BSSCI service bundles diff --git a/KC-Core/config/config.yaml b/KC-Core/config/config.yaml index 2b8b2b4..60171a7 100644 --- a/KC-Core/config/config.yaml +++ b/KC-Core/config/config.yaml @@ -32,6 +32,10 @@ protocol: max_retransmissions: 3 ack_timeout: 5000 connection_establishment_timeout: 30000 + status_request_interval: 30 + status_request_initial_delay: 5 + dlrx_query_timeout: 300 + dlrx_cleanup_interval: 60 duplicate_window: 300 message_encoding: "msgpack" diff --git a/KC-Core/internal/grpc/core_service_certs.go b/KC-Core/internal/grpc/core_service_certs.go index a0930ab..1fedd4f 100644 --- a/KC-Core/internal/grpc/core_service_certs.go +++ b/KC-Core/internal/grpc/core_service_certs.go @@ -33,8 +33,15 @@ func (s *CoreService) GenerateCertificate(ctx context.Context, req *pb.GenerateC grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenBasestationEUIRequired)) } - // Get tenant ID for certificate persistence (optional - persistence skipped if unavailable) - tenantID, _ := GetTenantFromContext(ctx) + // Tenant context is mandatory: certificate issuance is scoped to the tenant + // that owns the base station, and issuing without a tenant would allow + // minting a certificate for any EUI. + tenantID, tenantErr := GetTenantFromContext(ctx) + if tenantErr != nil || tenantID <= 0 { + s.log.WarnContext(ctx, "certificate issuance requires tenant context", "bs_eui", req.BsEui, "error", tenantErr) + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenMissingTenantCtx), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenMissingTenantCtx)) + } certReq := &grpcservices.CertificateRequest{ BsEUI: req.BsEui, diff --git a/KC-Core/internal/services/certificates/service.go b/KC-Core/internal/services/certificates/service.go index 865110a..ee5fea6 100644 --- a/KC-Core/internal/services/certificates/service.go +++ b/KC-Core/internal/services/certificates/service.go @@ -135,6 +135,19 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer return nil, fmt.Errorf("%s: %s", pkggrpc.ErrTokenInvalidValidityPeriod, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenInvalidValidityPeriod)) } + // Tenant ownership must be verified BEFORE any file or certgen work: the + // requesting tenant must already own a base station registered with this + // EUI, or a certificate could be minted for another tenant's station + // (cross-tenant impersonation). Fail closed. + if s.bsRepo != nil && req.TenantID > 0 { + euiBytes := binary.BigEndian.AppendUint64(nil, euiValue) + if _, ownErr := s.bsRepo.GetByEUI(ctx, req.TenantID, euiBytes); ownErr != nil { + s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, + "bs_eui", req.BsEUI, "tenant_id", req.TenantID, "error", ownErr) + return nil, fmt.Errorf("%s", pkggrpc.ErrTokenBaseStationNotFound) + } + } + // Generate unique ID for this certificate set certID := uuid.New().String() certDir := filepath.Join(s.tempDir, certID) @@ -257,11 +270,18 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer s.logger.ErrorContext(ctx, pkggrpc.LogCertInfoWriteFailed, "error", err) } - // Persist certs to base station when dependencies are configured + // Persistence is part of issuance: if the generated certificate and its + // encrypted key cannot be durably recorded on the base station, no + // certificate is returned and the temporary artifacts are removed, so a + // station never receives a certificate the service center did not persist. if s.bsRepo != nil && s.keyEncryptor != nil && req.TenantID > 0 { euiBytes := binary.BigEndian.AppendUint64(nil, euiValue) if persistErr := s.persistCertsToBaseStation(ctx, certDir, euiBytes, req.TenantID, certExpiryTime); persistErr != nil { - s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", persistErr) + s.logger.ErrorContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", persistErr) + if rmErr := os.RemoveAll(certDir); rmErr != nil { + s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) + } + return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCertPersistenceFailed, persistErr) } } else if s.bsRepo != nil && s.keyEncryptor == nil && req.TenantID > 0 { s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", pkggrpc.ErrTokenServiceNotConfigured) diff --git a/KC-Core/internal/services/certificates/service_test.go b/KC-Core/internal/services/certificates/service_test.go index 10d96c3..6d9bcb0 100644 --- a/KC-Core/internal/services/certificates/service_test.go +++ b/KC-Core/internal/services/certificates/service_test.go @@ -434,3 +434,67 @@ func TestNew_ServerValidityDaysFallsBackToDefault(t *testing.T) { config.DefaultCertificatesServerValidityDays, svc.serverValidityDays) } } + +// newGenerateTestServiceWithRepo mirrors newGenerateTestService but injects a +// base-station repository so the tenant-ownership check runs. +func newGenerateTestServiceWithRepo(t *testing.T, repo *mockBaseStationRepo) *Service { + t.Helper() + tmpDir := t.TempDir() + return &Service{ + config: &config.Config{}, + logger: &mockLogger{}, + certGenPath: filepath.Join(tmpDir, "certgen-missing"), + certsDir: tmpDir, + tempDir: filepath.Join(tmpDir, "temp"), + serverValidityDays: config.DefaultCertificatesServerValidityDays, + protocolConfig: &config.ProtocolConfig{}, + bsRepo: repo, + } +} + +// TestGenerateCertificate_CrossTenantDenied verifies a certificate cannot be +// minted for an EUI the requesting tenant does not own: the ownership lookup +// fails, so issuance is rejected before any certgen work. +func TestGenerateCertificate_CrossTenantDenied(t *testing.T) { + svc := newGenerateTestServiceWithRepo(t, &mockBaseStationRepo{getErr: errors.New("not found for tenant")}) + ctx := testutil.TestContext() + + _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "cafecafecafecafe", + ValidityDays: 365, + TenantID: 42, + }) + if err == nil { + t.Fatal("expected cross-tenant issuance to be denied") + } + if !strings.Contains(err.Error(), pkggrpc.ErrTokenBaseStationNotFound) { + t.Errorf("error = %q, want base-station-not-found ownership rejection", err.Error()) + } + // Must fail BEFORE reaching the (missing) certgen binary. + if strings.Contains(err.Error(), pkggrpc.ErrTokenCertGeneratorNotFound) { + t.Errorf("ownership check must run before generation, got %q", err.Error()) + } +} + +// TestGenerateCertificate_OwnedProceedsToGeneration verifies that when the +// requesting tenant owns the EUI, issuance proceeds past the ownership check +// (and here fails only at the intentionally-absent certgen binary). +func TestGenerateCertificate_OwnedProceedsToGeneration(t *testing.T) { + svc := newGenerateTestServiceWithRepo(t, &mockBaseStationRepo{bs: &models.BaseStation{ID: 1, TenantID: 42}}) + ctx := testutil.TestContext() + + _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "cafecafecafecafe", + ValidityDays: 365, + TenantID: 42, + }) + if err == nil { + t.Fatal("expected certgen-missing failure after a passing ownership check") + } + if strings.Contains(err.Error(), pkggrpc.ErrTokenBaseStationNotFound) { + t.Errorf("ownership check must pass for an owned EUI, got %q", err.Error()) + } + if !strings.Contains(err.Error(), pkggrpc.ErrTokenCertGeneratorNotFound) { + t.Errorf("error = %q, want it to reach the certgen step", err.Error()) + } +} diff --git a/KC-Core/pkg/bssci/constants.go b/KC-Core/pkg/bssci/constants.go index 03cfc83..f1c6bd3 100644 --- a/KC-Core/pkg/bssci/constants.go +++ b/KC-Core/pkg/bssci/constants.go @@ -32,6 +32,15 @@ const ( // defaultCertificatePollInterval is the certificate change poll interval defaultCertificatePollInterval = 10 * time.Second + + // defaultStatusRequestInterval is how often the SC polls a base station for status + defaultStatusRequestInterval = 30 * time.Second + // defaultStatusRequestInitialDelay delays the first status poll after connect + defaultStatusRequestInitialDelay = 5 * time.Second + // defaultDLRXQueryTimeout expires an unanswered dlRxStatQry + defaultDLRXQueryTimeout = 300 * time.Second + // defaultDLRXCleanupInterval is the dlRxStatQry expiry sweep cadence + defaultDLRXCleanupInterval = 60 * time.Second ) // Exact float integer bounds for wire numeric coercion. diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index 165794d..f2989b9 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -26,17 +26,17 @@ type VersionNegotiator interface { type ResumeDisposition int const ( - // ResumeNoMatch: no resumable session exists; start a fresh session. + // ResumeNoMatch means no resumable session exists; start a fresh session. ResumeNoMatch ResumeDisposition = iota - // ResumeCompatible: a resumable session was found and its constraints + // ResumeCompatible means a resumable session was found and its constraints // hold; Previous carries the authoritative persisted session state. ResumeCompatible - // ResumeInconsistent: a resumable session exists but the reported + // ResumeInconsistent means a resumable session exists but the reported // counters or negotiated version are incompatible. The caller must // atomically terminate it (can_resume=false, pending ops removed) before // starting a fresh session. ResumeInconsistent - // ResumeInfrastructureFailure: the resume lookup itself failed (e.g. a + // ResumeInfrastructureFailure means the resume lookup itself failed (e.g. a // database outage). The caller must reject the connect, never proceed // with a fresh session that would strand the old resumable state. ResumeInfrastructureFailure diff --git a/KC-Core/pkg/bssci/log_messages.go b/KC-Core/pkg/bssci/log_messages.go index c325d67..3c1ba67 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -55,6 +55,10 @@ const ( LogBSSCIRejectingCommandBeforeHandshake = "Rejecting command before handshake complete" // LogBSSCIRejectingInboundServiceCenterCommand logs when a base station sends a service-center-initiated command LogBSSCIRejectingInboundServiceCenterCommand = "Rejecting inbound service-center-initiated command from base station" + // LogBSSCIDLRXQueryExpirySweepFailed logs a failed dlRxStatQry expiry sweep + LogBSSCIDLRXQueryExpirySweepFailed = "DL RX status query expiry sweep failed" + // LogBSSCIDLRXQueriesExpired logs the count of expired dlRxStatQry queries + LogBSSCIDLRXQueriesExpired = "Expired stale DL RX status queries" // LogBSSCIConnectHandshakeNotComplete logs when connect handshake is incomplete LogBSSCIConnectHandshakeNotComplete = "Connect handshake not complete for session" // LogBSSCIConnectHandshakeNotCompleteDL logs when connect handshake is incomplete for downlink diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index 06fcaa1..9fa05d4 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -212,6 +212,14 @@ type Config struct { DuplicateWindow time.Duration // CertificatePollInterval is the certificate change poll interval CertificatePollInterval time.Duration + // StatusRequestInterval is how often the SC polls a base station for status + StatusRequestInterval time.Duration + // StatusRequestInitialDelay delays the first status poll after connect + StatusRequestInitialDelay time.Duration + // DLRXQueryTimeout expires an unanswered dlRxStatQry after this duration + DLRXQueryTimeout time.Duration + // DLRXCleanupInterval is the dlRxStatQry expiry sweep cadence + DLRXCleanupInterval time.Duration // DisableAttachPersistence is TEST-ONLY: skips DB persistence in attach handler. // MUST remain false in production. Used by tests to exercise replay protection without transaction stubs. DisableAttachPersistence bool @@ -883,6 +891,9 @@ func certsExist(certFile, keyFile, caFile string) bool { // (e.g., fresh deployment before certs are generated via UI), the listener // is deferred and a background goroutine polls until the certificates appear. func (s *Server) Start() error { + // The dlRxStatQry expiry sweep runs independently of the TLS listener path. + s.startDLRXQueryExpiryWorker() + if certsExist(s.config.TLSCert, s.config.TLSKey, s.config.TLSCACert) { return s.startTLSListener() } @@ -1598,6 +1609,74 @@ func certificatePollInterval(cfg *Config) time.Duration { return defaultCertificatePollInterval } +// statusRequestInterval returns the configured status poll interval, falling +// back to the package default when unset. +func (s *Server) statusRequestInterval() time.Duration { + if s.config != nil && s.config.StatusRequestInterval > 0 { + return s.config.StatusRequestInterval + } + return defaultStatusRequestInterval +} + +// statusRequestInitialDelay returns the configured delay before the first +// status poll, falling back to the package default when unset. +func (s *Server) statusRequestInitialDelay() time.Duration { + if s.config != nil && s.config.StatusRequestInitialDelay > 0 { + return s.config.StatusRequestInitialDelay + } + return defaultStatusRequestInitialDelay +} + +// dlrxQueryTimeout returns the configured dlRxStatQry expiry, falling back to +// the package default when unset. +func (s *Server) dlrxQueryTimeout() time.Duration { + if s.config != nil && s.config.DLRXQueryTimeout > 0 { + return s.config.DLRXQueryTimeout + } + return defaultDLRXQueryTimeout +} + +// dlrxCleanupInterval returns the configured dlRxStatQry expiry sweep cadence, +// falling back to the package default when unset. +func (s *Server) dlrxCleanupInterval() time.Duration { + if s.config != nil && s.config.DLRXCleanupInterval > 0 { + return s.config.DLRXCleanupInterval + } + return defaultDLRXCleanupInterval +} + +// startDLRXQueryExpiryWorker runs a background sweep that expires dlRxStatQry +// queries whose unsolicited dlRxStat report never arrived (BSSCI §5.16), so +// they do not linger pending forever. The worker stops when the server context +// is cancelled. +func (s *Server) startDLRXQueryExpiryWorker() { + if s.storage == nil || s.storage.DLRXStatus() == nil { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.dlrxCleanupInterval()) + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + cutoff := time.Now().Add(-s.dlrxQueryTimeout()) + expired, err := s.storage.DLRXStatus().ExpireDLRXStatusQuery(s.safeCtx(), cutoff) + if err != nil { + s.logger.WarnContext(s.safeCtx(), LogBSSCIDLRXQueryExpirySweepFailed, "error", err) + continue + } + if expired > 0 { + s.logger.InfoContext(s.safeCtx(), LogBSSCIDLRXQueriesExpired, "count", expired) + } + } + } + }() +} + // rejectConnect fails the connect operation per BSSCI §5.17: an error frame // replaces the normal response, the session enters AwaitingConnectErrorAck, // and the connection stays open until the base station acknowledges with diff --git a/KC-Core/pkg/bssci/status_handlers.go b/KC-Core/pkg/bssci/status_handlers.go index f4fa514..6caafaa 100644 --- a/KC-Core/pkg/bssci/status_handlers.go +++ b/KC-Core/pkg/bssci/status_handlers.go @@ -10,13 +10,6 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" ) -const ( - // StatusRequestInterval defines how often to request status from base stations - StatusRequestInterval = 30 * time.Second - // StatusRequestInitialDelay defines the initial delay before first status request - StatusRequestInitialDelay = 5 * time.Second -) - // handleStatusResponse handles statusRsp from base station per MIOTY BSSCI v1.0.0 Section 3.5.2 func (s *Server) handleStatusResponse(_ *Server, session *Session, msg *Message, data map[string]interface{}) error { if session == nil { @@ -289,10 +282,10 @@ func (s *Server) startStatusMechanism(session *Session) { session.mu.Unlock() go func() { - ticker := time.NewTicker(StatusRequestInterval) + ticker := time.NewTicker(s.statusRequestInterval()) defer ticker.Stop() - time.Sleep(StatusRequestInitialDelay) + time.Sleep(s.statusRequestInitialDelay()) if _, err := s.SendStatusRequest(session); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIInitialStatusRequestFailed, "bsEui", session.BaseStationEUI, diff --git a/KC-Core/pkg/config/constants.go b/KC-Core/pkg/config/constants.go index cc1b2bf..12e4071 100644 --- a/KC-Core/pkg/config/constants.go +++ b/KC-Core/pkg/config/constants.go @@ -392,6 +392,15 @@ const ( // DefaultProtocolConnectionEstablishmentTimeout bounds a fresh connection before its con arrives, in milliseconds. DefaultProtocolConnectionEstablishmentTimeout = 30000 + // DefaultProtocolStatusRequestInterval is how often the SC polls a base station for status, in seconds. + DefaultProtocolStatusRequestInterval = 30 + // DefaultProtocolStatusRequestInitialDelay delays the first status poll after connect, in seconds. + DefaultProtocolStatusRequestInitialDelay = 5 + // DefaultProtocolDLRXQueryTimeout expires an unanswered dlRxStatQry after this many seconds. + DefaultProtocolDLRXQueryTimeout = 300 + // DefaultProtocolDLRXCleanupInterval is the dlRxStatQry expiry sweep cadence, in seconds. + DefaultProtocolDLRXCleanupInterval = 60 + // DefaultProtocolDuplicateWindow is the duplicate detection window in seconds. DefaultProtocolDuplicateWindow = 300 diff --git a/KC-Core/pkg/config/loader.go b/KC-Core/pkg/config/loader.go index e589315..d8671de 100644 --- a/KC-Core/pkg/config/loader.go +++ b/KC-Core/pkg/config/loader.go @@ -167,6 +167,10 @@ func setDefaults(v *viper.Viper) { v.SetDefault("protocol.max_retransmissions", DefaultProtocolMaxRetransmissions) v.SetDefault("protocol.ack_timeout", DefaultProtocolAckTimeout) v.SetDefault("protocol.connection_establishment_timeout", DefaultProtocolConnectionEstablishmentTimeout) + v.SetDefault("protocol.status_request_interval", DefaultProtocolStatusRequestInterval) + v.SetDefault("protocol.status_request_initial_delay", DefaultProtocolStatusRequestInitialDelay) + v.SetDefault("protocol.dlrx_query_timeout", DefaultProtocolDLRXQueryTimeout) + v.SetDefault("protocol.dlrx_cleanup_interval", DefaultProtocolDLRXCleanupInterval) v.SetDefault("protocol.duplicate_window", DefaultProtocolDuplicateWindow) v.SetDefault("protocol.bsci_certificate_poll_interval", DefaultProtocolCertificatePollInterval) diff --git a/KC-Core/pkg/config/protocol.go b/KC-Core/pkg/config/protocol.go index 063eaaf..fdf39a8 100644 --- a/KC-Core/pkg/config/protocol.go +++ b/KC-Core/pkg/config/protocol.go @@ -31,6 +31,10 @@ type ProtocolConfig struct { ConnectionEstablishmentTimeout int `mapstructure:"connection_establishment_timeout"` // milliseconds DuplicateWindow int `mapstructure:"duplicate_window"` // seconds MessageEncoding string `mapstructure:"message_encoding"` // json, msgpack + StatusRequestInterval int `mapstructure:"status_request_interval"` // seconds + StatusRequestInitialDelay int `mapstructure:"status_request_initial_delay"` // seconds + DLRXQueryTimeout int `mapstructure:"dlrx_query_timeout"` // seconds + DLRXCleanupInterval int `mapstructure:"dlrx_cleanup_interval"` // seconds // BSCICertificatePollInterval is the base station certificate change poll interval BSCICertificatePollInterval time.Duration `mapstructure:"bsci_certificate_poll_interval"` diff --git a/KC-Core/pkg/config/service_center.go b/KC-Core/pkg/config/service_center.go index 632e0e1..7327207 100644 --- a/KC-Core/pkg/config/service_center.go +++ b/KC-Core/pkg/config/service_center.go @@ -36,6 +36,22 @@ func ValidateServiceCenterConfig(cfg *ProtocolConfig) error { return fmt.Errorf("protocol.connection_establishment_timeout must be positive milliseconds, got %d", cfg.ConnectionEstablishmentTimeout) } + if cfg.StatusRequestInterval <= 0 { + return fmt.Errorf("protocol.status_request_interval must be positive seconds, got %d", cfg.StatusRequestInterval) + } + + if cfg.StatusRequestInitialDelay < 0 { + return fmt.Errorf("protocol.status_request_initial_delay must not be negative, got %d", cfg.StatusRequestInitialDelay) + } + + if cfg.DLRXQueryTimeout <= 0 { + return fmt.Errorf("protocol.dlrx_query_timeout must be positive seconds, got %d", cfg.DLRXQueryTimeout) + } + + if cfg.DLRXCleanupInterval <= 0 { + return fmt.Errorf("protocol.dlrx_cleanup_interval must be positive seconds, got %d", cfg.DLRXCleanupInterval) + } + if cfg.DuplicateWindow <= 0 { return fmt.Errorf("protocol.duplicate_window must be positive seconds, got %d", cfg.DuplicateWindow) } diff --git a/KC-Core/pkg/config/service_center_test.go b/KC-Core/pkg/config/service_center_test.go index cd0eede..345ce49 100644 --- a/KC-Core/pkg/config/service_center_test.go +++ b/KC-Core/pkg/config/service_center_test.go @@ -341,6 +341,10 @@ func TestValidateServiceCenterConfig_Valid(t *testing.T) { BSCITLS: TLSConfig{Enabled: true}, AckTimeout: DefaultProtocolAckTimeout, ConnectionEstablishmentTimeout: DefaultProtocolConnectionEstablishmentTimeout, + StatusRequestInterval: DefaultProtocolStatusRequestInterval, + StatusRequestInitialDelay: DefaultProtocolStatusRequestInitialDelay, + DLRXQueryTimeout: DefaultProtocolDLRXQueryTimeout, + DLRXCleanupInterval: DefaultProtocolDLRXCleanupInterval, DuplicateWindow: DefaultProtocolDuplicateWindow, BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, } @@ -359,6 +363,10 @@ func TestValidateServiceCenterConfig_TimingBounds(t *testing.T) { BSCITLS: TLSConfig{Enabled: true}, AckTimeout: DefaultProtocolAckTimeout, ConnectionEstablishmentTimeout: DefaultProtocolConnectionEstablishmentTimeout, + StatusRequestInterval: DefaultProtocolStatusRequestInterval, + StatusRequestInitialDelay: DefaultProtocolStatusRequestInitialDelay, + DLRXQueryTimeout: DefaultProtocolDLRXQueryTimeout, + DLRXCleanupInterval: DefaultProtocolDLRXCleanupInterval, DuplicateWindow: DefaultProtocolDuplicateWindow, BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, } @@ -376,6 +384,24 @@ func TestValidateServiceCenterConfig_TimingBounds(t *testing.T) { t.Error("zero connection_establishment_timeout must fail validation") } + zeroStatusInterval := base() + zeroStatusInterval.StatusRequestInterval = 0 + if err := ValidateServiceCenterConfig(zeroStatusInterval); err == nil { + t.Error("zero status_request_interval must fail validation") + } + + zeroDLRXTimeout := base() + zeroDLRXTimeout.DLRXQueryTimeout = 0 + if err := ValidateServiceCenterConfig(zeroDLRXTimeout); err == nil { + t.Error("zero dlrx_query_timeout must fail validation") + } + + zeroDLRXCleanup := base() + zeroDLRXCleanup.DLRXCleanupInterval = 0 + if err := ValidateServiceCenterConfig(zeroDLRXCleanup); err == nil { + t.Error("zero dlrx_cleanup_interval must fail validation") + } + negativeWindow := base() negativeWindow.DuplicateWindow = -1 if err := ValidateServiceCenterConfig(negativeWindow); err == nil { diff --git a/KC-Core/pkg/grpc/errors_catalog.go b/KC-Core/pkg/grpc/errors_catalog.go index e593e61..74b2777 100644 --- a/KC-Core/pkg/grpc/errors_catalog.go +++ b/KC-Core/pkg/grpc/errors_catalog.go @@ -125,6 +125,7 @@ const ( ErrTokenCertGeneratorNotFound = "KC-GRPC-ERR-03A" ErrTokenCACertReadFailed = "KC-GRPC-ERR-03B" ErrTokenCACertCopyFailed = "KC-GRPC-ERR-03C" + ErrTokenCertPersistenceFailed = "KC-GRPC-ERR-0AA" ErrTokenCAKeyReadFailed = "KC-GRPC-ERR-03D" ErrTokenCAKeyCopyFailed = "KC-GRPC-ERR-03E" ErrTokenInvalidValidityPeriod = "KC-GRPC-ERR-03F" @@ -876,6 +877,11 @@ var errorCatalog = map[string]ErrorDefinition{ Message: "failed to copy CA certificate", Code: codes.Internal, }, + ErrTokenCertPersistenceFailed: { + Token: ErrTokenCertPersistenceFailed, + Message: "failed to persist issued certificate; no certificate was returned", + Code: codes.Internal, + }, ErrTokenCAKeyReadFailed: { Token: ErrTokenCAKeyReadFailed, Message: "failed to read CA private key", diff --git a/KC-DB/migrations/000142_add_dlrx_correlation_index.down.sql b/KC-DB/migrations/000142_add_dlrx_correlation_index.down.sql new file mode 100644 index 0000000..770327f --- /dev/null +++ b/KC-DB/migrations/000142_add_dlrx_correlation_index.down.sql @@ -0,0 +1,7 @@ +-- Migration 000142 down: drop the correlation index and restore the prior +-- (less precise) table comment. The bs_eui column comment is left in place; it +-- documents an existing column accurately. +DROP INDEX IF EXISTS idx_dl_rx_status_queries_correlation; + +COMMENT ON TABLE dl_rx_status_queries IS + 'Tracks DL RX status queries (BSSCI 5.15) and their responses.'; diff --git a/KC-DB/migrations/000142_add_dlrx_correlation_index.up.sql b/KC-DB/migrations/000142_add_dlrx_correlation_index.up.sql new file mode 100644 index 0000000..b8c06fe --- /dev/null +++ b/KC-DB/migrations/000142_add_dlrx_correlation_index.up.sql @@ -0,0 +1,15 @@ +-- Migration 000142: index DL RX status query correlation and clarify spec sections. +-- +-- The dlRxStat report correlation now matches tenant + endpoint + expected +-- base station + status (see MarkDLRXStatusReceived), so add a covering index +-- for that lookup. Also correct the table comments: the dlRxStatQry *query* is +-- BSSCI §5.16 while the unsolicited dlRxStat *report* is §5.15 - migration 066 +-- described both as §5.15. + +CREATE INDEX IF NOT EXISTS idx_dl_rx_status_queries_correlation + ON dl_rx_status_queries (tenant_id, ep_eui, bs_eui, status, requested_at); + +COMMENT ON TABLE dl_rx_status_queries IS + 'Tracks SC-initiated dlRxStatQry queries (BSSCI 5.16) awaiting the base station''s unsolicited dlRxStat report (BSSCI 5.15).'; +COMMENT ON COLUMN dl_rx_status_queries.bs_eui IS + 'Expected base station EUI the query targets; a dlRxStat report is correlated to a query only when its BS EUI matches.'; diff --git a/KC-DB/storage/models/integration.go b/KC-DB/storage/models/integration.go index c60307f..362ea42 100644 --- a/KC-DB/storage/models/integration.go +++ b/KC-DB/storage/models/integration.go @@ -18,7 +18,7 @@ type Integration struct { Description *string `db:"description" json:"description,omitempty"` Type string `db:"type" json:"type"` // http, mqtt, database Config json.RawMessage `db:"config" json:"config"` // Type-specific configuration - EventFilter json.RawMessage `db:"event_filter" json:"eventFilter,omitempty"` // Optional event filtering rules + EventFilter NullJSON `db:"event_filter" json:"eventFilter,omitempty"` // Optional event filtering rules DeliveryFormat string `db:"delivery_format" json:"deliveryFormat"` // json (default) Status string `db:"status" json:"status"` // active, paused, disabled CreatedAt time.Time `db:"created_at" json:"createdAt"` diff --git a/KC-DB/storage/postgres/dl_rx_status_correlation_test.go b/KC-DB/storage/postgres/dl_rx_status_correlation_test.go new file mode 100644 index 0000000..11f6e63 --- /dev/null +++ b/KC-DB/storage/postgres/dl_rx_status_correlation_test.go @@ -0,0 +1,76 @@ +package postgres + +import ( + "encoding/binary" + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMarkDLRXStatusReceived_CrossBaseStationIsolation verifies BSSCI §5.16 +// correlation: two concurrent dlRxStatQry queries for the same tenant and +// endpoint but different base stations must be satisfied independently - a +// dlRxStat report from one base station only marks that base station's query, +// never the other's. +func TestMarkDLRXStatusReceived_CrossBaseStationIsolation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + db, cleanup := SetupPostgresContainer(t) + defer cleanup() + + const tenantID = int64(600) + createTestTenant(t, db, tenantID, "TestTenantDLRXCorrelation") + + repo := NewDLRXStatusRepository(db, logger.NewNop()) + ctx := testutil.TestContext() + + epEui := euiBytesFromUint64(0x1122334455667788) + bsA := euiBytesFromUint64(0xAAAAAAAAAAAAAAAA) + bsB := euiBytesFromUint64(0xBBBBBBBBBBBBBBBB) + + // Two pending queries for the same endpoint, different base stations. + require.NoError(t, repo.CreateDLRXStatusQuery(ctx, tenantID, nil, epEui, bsA, -1)) + require.NoError(t, repo.CreateDLRXStatusQuery(ctx, tenantID, nil, epEui, bsB, -2)) + + // A report from base station A must mark only A's query. + matched, err := repo.MarkDLRXStatusReceived(ctx, tenantID, epEui, bsA, 5) + require.NoError(t, err) + assert.True(t, matched, "base station A's report must match its own query") + + assertQueryStatus(t, db, tenantID, epEui, bsA, "received") + assertQueryStatus(t, db, tenantID, epEui, bsB, "pending") + + // A report from base station B then marks B's query. + matched, err = repo.MarkDLRXStatusReceived(ctx, tenantID, epEui, bsB, 6) + require.NoError(t, err) + assert.True(t, matched, "base station B's report must match its own query") + assertQueryStatus(t, db, tenantID, epEui, bsB, "received") + + // A report for a base station with no pending query matches nothing. + matched, err = repo.MarkDLRXStatusReceived(ctx, tenantID, epEui, euiBytesFromUint64(0xCCCCCCCCCCCCCCCC), 7) + require.NoError(t, err) + assert.False(t, matched, "a report for an un-queried base station must not match") +} + +func euiBytesFromUint64(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +func assertQueryStatus(t *testing.T, db *sqlx.DB, tenantID int64, epEui, bsEui []byte, want string) { + t.Helper() + var status string + err := db.QueryRow( + `SELECT status FROM dl_rx_status_queries WHERE tenant_id = $1 AND ep_eui = $2 AND bs_eui = $3`, + tenantID, epEui, bsEui, + ).Scan(&status) + require.NoError(t, err) + assert.Equal(t, want, status) +} diff --git a/KC-DB/storage/postgres/dl_rx_status_repository.go b/KC-DB/storage/postgres/dl_rx_status_repository.go index d856a41..fde9a91 100644 --- a/KC-DB/storage/postgres/dl_rx_status_repository.go +++ b/KC-DB/storage/postgres/dl_rx_status_repository.go @@ -305,10 +305,11 @@ func (r *DLRXStatusRepository) CreateDLRXStatusQuery(ctx context.Context, tenant return nil } -// MarkDLRXStatusReceived marks the OLDEST pending query for tenant+endpoint as received -// Correlates by tenant+endpoint only, selecting oldest pending (BSSCI §5.15) -// BS opId lives in different namespace - stored for audit, NOT used in WHERE clause -// Returns true if a pending query was found and updated, false otherwise +// MarkDLRXStatusReceived marks the OLDEST pending query for the tenant, endpoint, +// AND expected base station as received (BSSCI §5.15). Correlating on the expected +// bs_eui prevents a report from one base station satisfying a concurrent query +// issued for a different base station. The actual BS EUI/opId are recorded for +// audit. Returns true if a matching pending query was found and updated. func (r *DLRXStatusRepository) MarkDLRXStatusReceived(ctx context.Context, tenantID int64, epEui []byte, bsEui []byte, bsOpID int64) (bool, error) { ctx, cancel := context.WithTimeout(ctx, dbconfig.DefaultQueryTimeout) defer cancel() @@ -319,6 +320,7 @@ func (r *DLRXStatusRepository) MarkDLRXStatusReceived(ctx context.Context, tenan SELECT id FROM dl_rx_status_queries WHERE tenant_id = $1 AND ep_eui = $2 + AND bs_eui = $3 AND status = 'pending' ORDER BY requested_at ASC LIMIT 1 diff --git a/KC-DB/storage/postgres/migration_discovery_test.go b/KC-DB/storage/postgres/migration_discovery_test.go index aa5b886..75a3661 100644 --- a/KC-DB/storage/postgres/migration_discovery_test.go +++ b/KC-DB/storage/postgres/migration_discovery_test.go @@ -12,8 +12,9 @@ import ( func TestMigrationDiscovery(t *testing.T) { migrations := discoverMigrations(t) - // We have 133 migration files (numbered 1-139 with gaps at 24, 25, 26, 52, 78, 79) - assert.Len(t, migrations, 133, "Should discover exactly 133 migrations") + // We have 135 migration files (numbered 1-142 with gaps at 24, 25, 26, 52, + // 78, 79 and 141 reserved for the downlink dispatch saga) + assert.Len(t, migrations, 135, "Should discover exactly 135 migrations") // Verify migrations are sorted by number for i := 1; i < len(migrations); i++ { @@ -26,7 +27,7 @@ func TestMigrationDiscovery(t *testing.T) { assert.NotEmpty(t, migrations[0].description, "Migration should have description") // Verify last migration - assert.Equal(t, 139, migrations[len(migrations)-1].number, "Last migration should be #139") + assert.Equal(t, 142, migrations[len(migrations)-1].number, "Last migration should be #142") // Verify specific migrations exist expectedMigrations := map[int]string{ @@ -97,6 +98,8 @@ func TestMigrationDiscovery(t *testing.T) { 137: "catalog_ownership", 138: "blueprint_default_unique", 139: "restore_message_archival", + 140: "purge_completed_bssci_pending_operations", + 142: "add_dlrx_correlation_index", } for num, expectedDesc := range expectedMigrations { diff --git a/KC-Web/src/modules/base-stations/components/BsConfigSummary.tsx b/KC-Web/src/modules/base-stations/components/BsConfigSummary.tsx index 5ca145b..6a6b197 100644 --- a/KC-Web/src/modules/base-stations/components/BsConfigSummary.tsx +++ b/KC-Web/src/modules/base-stations/components/BsConfigSummary.tsx @@ -5,8 +5,8 @@ import React from "react"; -import { Box, IconButton, Paper, Tooltip, Typography } from "@mui/material"; import type { Theme } from "@mui/material"; +import { Box, IconButton, Paper, Tooltip, Typography } from "@mui/material"; import { ACTION_COPIED, diff --git a/KC-Web/src/modules/base-stations/components/ScUrlCopyField.tsx b/KC-Web/src/modules/base-stations/components/ScUrlCopyField.tsx index 3c11ff2..f860163 100644 --- a/KC-Web/src/modules/base-stations/components/ScUrlCopyField.tsx +++ b/KC-Web/src/modules/base-stations/components/ScUrlCopyField.tsx @@ -6,8 +6,8 @@ import React from "react"; -import { IconButton, InputAdornment, TextField, Tooltip } from "@mui/material"; import type { Theme } from "@mui/material"; +import { IconButton, InputAdornment, TextField, Tooltip } from "@mui/material"; import { BASE_STATION_DETAILS } from "@constants/messages"; import { CheckCircleIcon, ContentCopyIcon } from "@theme/icons"; diff --git a/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx b/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx index a3dd97d..b8a35c5 100644 --- a/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx +++ b/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx @@ -28,8 +28,8 @@ import { MIOTY_EUI_REGEX, MIOTY_KEY_BYTE_LENGTH } from "@constants/app"; import { ENDPOINT_FORM } from "@constants/messages"; import { CheckCircleIcon, InfoIcon } from "@theme/icons"; -import { SecurityKeyFields } from "./EndpointFormFields"; import DeviceModelSelector from "./DeviceModelSelector"; +import { SecurityKeyFields } from "./EndpointFormFields"; import EndpointSettingsSection from "./EndpointSettingsSection"; interface AddEndPointDialogProps { diff --git a/config/config.docker.yaml b/config/config.docker.yaml index f63776d..ca0d23b 100644 --- a/config/config.docker.yaml +++ b/config/config.docker.yaml @@ -33,6 +33,11 @@ protocol: # Protocol parameters max_retransmissions: 3 ack_timeout: 5000 + connection_establishment_timeout: 30000 + status_request_interval: 30 + status_request_initial_delay: 5 + dlrx_query_timeout: 300 + dlrx_cleanup_interval: 60 duplicate_window: 300 message_encoding: "msgpack" From 44771d554c3221e9dc2dc43c96abdf51a5226b78 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:56 +0200 Subject: [PATCH 05/25] feat(bssci): converge downlink dispatch and make operation order durable Downlink dispatch (CHIRP-3543) converges on a single owner of the pending -> reserved -> queued lifecycle: exact queue-ID reservation, idempotent queued confirmation, batch pending-operation insert, and dlDataQueRsp repair. A downlink is claimed by exactly one dispatcher and is neither lost nor sent twice. Operation durability (CHIRP-3542): service-center operation IDs are allocated monotonically and never rolled back, the counter is persisted before the pending record, and the frame is written last. An ambiguous write closes the transport so recovery happens through resume with the original IDs. Connect-stage error exchanges now terminate cleanly and a NULL negotiated version stays empty rather than failing the resume. --- .../internal/grpc/core_service_dldata_test.go | 11 +- KC-Core/internal/grpc/core_service_test.go | 10 +- .../services/bssci/downlink_dispatcher.go | 158 +++- .../bssci/downlink_dispatcher_test.go | 536 ++++++------- .../services/bssci/session_resume_db_test.go | 27 + .../services/bssci/session_service.go | 10 +- .../internal/services/bssci/status_service.go | 89 ++- .../bssci/status_service_integration_test.go | 7 + .../internal/services/bssci/test_helpers.go | 8 + KC-Core/internal/services/scaci/dl_service.go | 2 +- .../services/scaci/dl_service_test.go | 2 +- .../attach_propagate_integration_test.go | 9 + KC-Core/pkg/bssci/connect_failure_test.go | 117 +++ KC-Core/pkg/bssci/constants.go | 2 +- KC-Core/pkg/bssci/contracts.go | 37 +- KC-Core/pkg/bssci/detach_integration_test.go | 3 + .../bssci/detach_propagate_complete_test.go | 4 + KC-Core/pkg/bssci/dl_queue_pair_test.go | 170 +++++ KC-Core/pkg/bssci/downlink_handlers.go | 429 ++++++----- KC-Core/pkg/bssci/downlink_handlers_test.go | 1 + .../pkg/bssci/error_ack_disposition_test.go | 144 ++++ KC-Core/pkg/bssci/errors_catalog.go | 36 +- KC-Core/pkg/bssci/log_messages.go | 10 + .../bssci/mandatory_field_validation_test.go | 21 +- KC-Core/pkg/bssci/message_assembly_test.go | 47 +- KC-Core/pkg/bssci/pending_op_leak_test.go | 46 +- KC-Core/pkg/bssci/server.go | 707 ++++++++++++------ .../pkg/bssci/session_opid_rollback_test.go | 339 ++------- KC-Core/pkg/bssci/status_handlers.go | 47 +- KC-Core/pkg/bssci/sublayer_test.go | 1 + KC-Core/pkg/bssci/test_helpers_test.go | 33 +- KC-Core/pkg/bssci/ul_transmit.go | 60 +- KC-Core/pkg/bssci/validation_status_test.go | 9 +- KC-Core/pkg/bssci/version_interop_test.go | 84 +++ KC-Core/pkg/bssci/vm_handlers.go | 149 ++-- KC-Core/pkg/scaci/handler_operations.go | 20 +- KC-Core/pkg/scaci/handler_operations_test.go | 22 +- KC-Core/pkg/scheduler/interfaces.go | 10 +- .../interfaces/mioty_downlink_repository.go | 19 +- KC-DB/storage/interfaces/repository.go | 7 + .../postgres/pending_operation_repository.go | 42 +- KC-DB/storage/postgres/postgres.go | 15 +- .../storage/postgres/transaction_downlink.go | 193 ++++- 43 files changed, 2378 insertions(+), 1315 deletions(-) create mode 100644 KC-Core/pkg/bssci/connect_failure_test.go create mode 100644 KC-Core/pkg/bssci/dl_queue_pair_test.go create mode 100644 KC-Core/pkg/bssci/error_ack_disposition_test.go diff --git a/KC-Core/internal/grpc/core_service_dldata_test.go b/KC-Core/internal/grpc/core_service_dldata_test.go index 271bc0e..780a041 100644 --- a/KC-Core/internal/grpc/core_service_dldata_test.go +++ b/KC-Core/internal/grpc/core_service_dldata_test.go @@ -282,6 +282,12 @@ type fakeStatusSvc struct{} func (f *fakeStatusSvc) RecordPendingOperation(_ context.Context, _ *bssci.Session, _ int64, _ *bssci.PendingOperation, _ int64) error { return nil } + +func (f *fakeStatusSvc) RecordPendingOperations(_ context.Context, _ *bssci.Session, _ []*bssci.PendingOperation, _ int64) error { + return nil +} + +func (f *fakeStatusSvc) RestorePendingOperation(_ *bssci.Session, _ int64, _ *bssci.PendingOperation) {} func (f *fakeStatusSvc) GetPendingOperation(_ *bssci.Session, _ int64) (*bssci.PendingOperation, error) { return nil, nil } @@ -291,14 +297,13 @@ func (f *fakeStatusSvc) RemovePendingOperation(_ context.Context, _ *bssci.Sessi func (f *fakeStatusSvc) ExtractQueueMetadata(_ *bssci.Session, _ int64) (uint64, int64, string) { return 0, 0, "" } -func (f *fakeStatusSvc) CleanupPendingOp(_ *bssci.Session, _ int64) {} // fakeDownlinkCmd implements bssci.DownlinkCommander (minimal for SendDownlink tests) type fakeDownlinkCmd struct { lastPacketCnt []int64 // Captures packet counter values sent to base station } -func (f *fakeDownlinkCmd) SendDLDataQueue(_ string, _ uint64, _ [][]byte, _ int64, _ float32, _ bool, packetCnt []int64, _ uint8, _ bool, _ bool, _ bool, _ bool, _ int64) error { +func (f *fakeDownlinkCmd) SendDLDataQueue(_ string, _ uint64, _ [][]byte, _ int64, _ float32, _ bool, packetCnt []int64, _ uint8, _ bool, _ bool, _ bool, _ bool, _ int64, _ bool) error { f.lastPacketCnt = packetCnt return nil } @@ -327,7 +332,7 @@ func (f *fakePingCmd) InitiatePing(_ context.Context, _ uint64, _ int64) (int64, // fakeDownlinkScheduler implements scheduler.DownlinkScheduler (minimal for SendDownlink tests) type fakeDownlinkScheduler struct{} -func (f *fakeDownlinkScheduler) QueueDownlink(_ *mioty.DLDataQueue, _ int64) (uint64, uint64, error) { +func (f *fakeDownlinkScheduler) QueueDownlink(_ context.Context, _ *mioty.DLDataQueue, _ int64) (uint64, uint64, error) { return 12345, 0x0102030405060708, nil } diff --git a/KC-Core/internal/grpc/core_service_test.go b/KC-Core/internal/grpc/core_service_test.go index 41e0574..7f1c1d9 100644 --- a/KC-Core/internal/grpc/core_service_test.go +++ b/KC-Core/internal/grpc/core_service_test.go @@ -246,6 +246,7 @@ func (m *mockDownlinkCmd) SendDLDataQueue( _ bool, _ bool, _ int64, + _ bool, ) error { return nil } @@ -2238,6 +2239,12 @@ type stubStatusSvc struct{} func (s *stubStatusSvc) RecordPendingOperation(_ context.Context, _ *bssci.Session, _ int64, _ *bssci.PendingOperation, _ int64) error { return nil } + +func (s *stubStatusSvc) RecordPendingOperations(_ context.Context, _ *bssci.Session, _ []*bssci.PendingOperation, _ int64) error { + return nil +} + +func (s *stubStatusSvc) RestorePendingOperation(_ *bssci.Session, _ int64, _ *bssci.PendingOperation) {} func (s *stubStatusSvc) GetPendingOperation(_ *bssci.Session, _ int64) (*bssci.PendingOperation, error) { return nil, nil } @@ -2247,11 +2254,10 @@ func (s *stubStatusSvc) RemovePendingOperation(_ context.Context, _ *bssci.Sessi func (s *stubStatusSvc) ExtractQueueMetadata(_ *bssci.Session, _ int64) (uint64, int64, string) { return 0, 0, "" } -func (s *stubStatusSvc) CleanupPendingOp(_ *bssci.Session, _ int64) {} type stubDownlinkScheduler struct{} -func (s *stubDownlinkScheduler) QueueDownlink(_ *mioty.DLDataQueue, _ int64) (uint64, uint64, error) { +func (s *stubDownlinkScheduler) QueueDownlink(_ context.Context, _ *mioty.DLDataQueue, _ int64) (uint64, uint64, error) { return 0, 0, nil } func (s *stubDownlinkScheduler) RevokeDownlink(_ int64, _ uint64) (uint64, error) { return 0, nil } diff --git a/KC-Core/internal/services/bssci/downlink_dispatcher.go b/KC-Core/internal/services/bssci/downlink_dispatcher.go index 68a4247..9fd8942 100644 --- a/KC-Core/internal/services/bssci/downlink_dispatcher.go +++ b/KC-Core/internal/services/bssci/downlink_dispatcher.go @@ -3,22 +3,31 @@ package bssciservices import ( "context" "encoding/binary" + "errors" + "strconv" "time" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/google/uuid" ) -// downlinkDispatcher implements bssci.DownlinkDispatcher (BSSCI §5.10.2). +// downlinkDispatcher implements bssci.DownlinkDispatcher (BSSCI rev1 §5.12 / +// classic §3.12). // -// Handles automatic downlink dispatch when endpoint signals dlOpen=true. -// Uses transactional reserve→send→mark-queued pattern with FOR UPDATE SKIP LOCKED -// to prevent concurrent dispatchers from reserving the same downlink. +// The dispatcher is the single owner of the downlink queue status lifecycle +// pending → reserved → queued for both delivery paths: DispatchIfAvailable +// (auto-dispatch on dlOpen=true) and DispatchQueue (SCACI-initiated immediate +// dispatch). The reservation commits in a short transaction BEFORE any network +// I/O; an uncertain (ambiguous) send leaves the row reserved and is confirmed +// by the idempotent reserved→queued update, repeated from the dlDataQueRsp +// handler for crash recovery. A definite pre-write failure releases the row +// back to pending (documented at-least-once retry semantics). type downlinkDispatcher struct { logger logger.Logger - storage interfaces.Storage // interfaces.Storage for BeginTx() + storage interfaces.Storage // BeginTx() for reservation + regular repository for confirmation sendFn SendDLQueueFunc // Injected function matching Server.SendDLDataQueue signature } @@ -26,7 +35,8 @@ type downlinkDispatcher struct { // Enables dependency injection for testability without depending on full Server type SendDLQueueFunc func(sessionID string, epEUI uint64, payloads [][]byte, queID int64, priority float32, cntDepend bool, packetCnt []int64, format uint8, - responseExp, responsePrio, dlWindReq, expOnly bool, tenantID int64) error + responseExp, responsePrio, dlWindReq, expOnly bool, tenantID int64, + dlRxStatQry bool) error // Ensure interface compliance var _ bssci.DownlinkDispatcher = (*downlinkDispatcher)(nil) @@ -35,7 +45,7 @@ var _ bssci.DownlinkDispatcher = (*downlinkDispatcher)(nil) // // Parameters: // - log: Logger for dispatch events -// - storage: interfaces.Storage providing BeginTx() for transactions +// - storage: interfaces.Storage providing BeginTx() and the downlink repository // - sendFn: Function to send downlink (typically bssciServer.SendDLDataQueue) func NewDownlinkDispatcher( log logger.Logger, @@ -49,18 +59,14 @@ func NewDownlinkDispatcher( } } -// DispatchIfAvailable performs transactional reserve→send→mark-queued for pending downlinks. +// DispatchIfAvailable reserves the highest-priority pending downlink for the +// endpoint and dispatches it through the shared dispatch path. // // Lifecycle: -// 1. Begin transaction -// 2. ReserveNextPendingDownlink (SELECT FOR UPDATE SKIP LOCKED + UPDATE status='reserved') -// 3. Build payloads from DownlinkMessage -// 4. Send via SendDLDataQueue (three-way handshake) -// 5. MarkReservedAsQueued (UPDATE status='queued', transmission_time, tx_bs_eui) -// 6. Commit transaction -// -// If step 4 fails, transaction rollback returns status to 'pending' automatically. -// If step 5 fails, transaction rollback returns status to 'reserved' then 'pending'. +// 1. Short transaction: ReserveNextPendingDownlink (FOR UPDATE SKIP LOCKED + +// UPDATE status='reserved'), then commit BEFORE any network I/O +// 2. Send via SendDLDataQueue (dlRxStatQry pairing included) +// 3. Idempotent reserved→queued confirmation on the regular repository func (d *downlinkDispatcher) DispatchIfAvailable( ownerCtx context.Context, ownerTenantID int64, @@ -80,18 +86,20 @@ func (d *downlinkDispatcher) DispatchIfAvailable( epEUIBytes := make([]byte, 8) binary.BigEndian.PutUint64(epEUIBytes, epEUI) - // Begin transaction via interfaces.Storage + // Short reservation transaction: reserve and commit before any wire write tx, err := d.storage.BeginTx(ownerCtx) if err != nil { d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherTxBeginFailed, "error", err) return false, nil // Graceful degradation - don't fail uplink } + rollback := true defer func() { - // Rollback is no-op if already committed - _ = tx.Rollback() + if rollback { + _ = tx.Rollback() + } }() - // 1. Atomic select + reserve with SKIP LOCKED + // Atomic select + reserve with SKIP LOCKED // Pass nil for orgID since BSSCI dispatcher uses tenant-level isolation dl, err := tx.MIOTYDownlinks().ReserveNextPendingDownlink(ownerCtx, ownerTenantID, epEUIBytes, session.BaseStationEUI, nil) if err != nil { @@ -103,14 +111,83 @@ func (d *downlinkDispatcher) DispatchIfAvailable( return false, nil // No pending downlinks - normal case } - // 2. Build payloads array (UserData takes precedence, fallback to single Payload) + if err := tx.Commit(); err != nil { + d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherTxCommitFailed, "error", err) + return false, nil + } + rollback = false + + return d.dispatchReserved(ownerCtx, ownerTenantID, ownerOrgUUID, session, epEUI, dl) +} + +// DispatchQueue reserves one exact pending queue row (by queue ID, tenant, and +// endpoint) and dispatches it through the shared dispatch path. Used for +// SCACI-initiated immediate delivery (SCACI §3.10.1). Returns dispatched=false +// with a nil error when no matching row is in 'pending' state (already +// dispatched, revoked, or foreign). +func (d *downlinkDispatcher) DispatchQueue( + ownerCtx context.Context, + ownerTenantID int64, + ownerOrgUUID uuid.UUID, + session *bssci.Session, + queueID uint64, + epEUI uint64, +) (bool, error) { + if ownerTenantID == 0 { + d.logger.WarnContext(ownerCtx, bssci.LogDispatcherNoTenant, "epEui", epEUI) + return false, nil + } + + epEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(epEUIBytes, epEUI) + + // Single-statement exact reservation (pending → reserved), atomic without + // an explicit transaction. Org filter stays nil: BSSCI dispatch uses + // tenant-level isolation, matching DispatchIfAvailable. + dl, err := d.storage.MIOTYDownlinks().ReservePendingDownlinkByQueueID(ownerCtx, ownerTenantID, nil, queueID, epEUIBytes, session.BaseStationEUI) + if err != nil { + d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherQueryFailed, "error", err, "epEui", epEUI, "queId", queueID) + return false, err + } + if dl == nil { + d.logger.WarnContext(ownerCtx, bssci.LogDispatcherNoPending, "epEui", epEUI, "queId", queueID) + return false, nil + } + + return d.dispatchReserved(ownerCtx, ownerTenantID, ownerOrgUUID, session, epEUI, dl) +} + +// dispatchReserved sends a reserved queue row and confirms it as queued. The +// row is already durably 'reserved' and no transaction is open. +// +// Failure semantics: +// - Ambiguous wire write (bssci.ErrAmbiguousWrite): the row stays reserved - +// never back to pending, because a duplicate send with a new operation ID +// could corrupt the stream. The send layer closes the connection; resume +// reissues the persisted operations with their original IDs and the +// dlDataQueRsp handler repairs the reserved→queued status. +// - Definite pre-write failure: the row is released back to pending +// (documented at-least-once retry). +// - Queued-confirmation failure after a successful send: the row stays +// reserved and the send is reported dispatched; the idempotent +// confirmation is repeated from the dlDataQueRsp handler. +func (d *downlinkDispatcher) dispatchReserved( + ownerCtx context.Context, + ownerTenantID int64, + ownerOrgUUID uuid.UUID, + session *bssci.Session, + epEUI uint64, + dl *storage.DownlinkMessage, +) (bool, error) { + // Build payloads array (UserData takes precedence, fallback to single Payload) payloads := dl.UserData if len(payloads) == 0 && len(dl.Payload) > 0 { payloads = [][]byte{dl.Payload} } - // 3. Dispatch via SendDLDataQueue (uses existing three-way handshake) - err = d.sendFn( + // Dispatch via SendDLDataQueue (three-way handshake; pairs the §5.16 / + // §3.16 dlRxStatQry ahead of the queue frame when the row requests it) + err := d.sendFn( session.ID, epEUI, payloads, @@ -124,19 +201,30 @@ func (d *downlinkDispatcher) DispatchIfAvailable( dl.DlWindReq, dl.ExpOnly, ownerTenantID, + dl.DlRxStatQry, ) if err != nil { + if errors.Is(err, bssci.ErrAmbiguousWrite) { + d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherSendFailed, + "queId", dl.QueID, "epEui", epEUI, "error", err) + return false, err + } + // Definite pre-write failure: release the reservation for retry d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherSendFailed, "queId", dl.QueID, "epEui", epEUI, "error", err) - // tx.Rollback() called by defer - reserved->pending automatically - return false, nil + if relErr := d.storage.MIOTYDownlinks().UpdateDownlinkStatus(ownerCtx, + strconv.FormatInt(dl.ID, 10), bssci.DLQueueStatusPending, nil); relErr != nil { + d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherReleaseFailed, + "queId", dl.QueID, "error", relErr) + } + return false, err } - // 4. Mark sent with transmission metadata - // packetCnt = nil (unknown until the transmission result arrives via dlDataRes, BSSCI 5.14) - // Pass nil for orgID since BSSCI dispatcher uses tenant-level isolation + // Confirm reserved→queued with transmission metadata (idempotent single + // statement on the regular repository - no transaction spans the send). + // packetCnt = nil (unknown until the dlDataRes transmission result, BSSCI 5.14) txTime := time.Now().UnixNano() - if err := tx.MIOTYDownlinks().MarkReservedAsQueued( + if err := d.storage.MIOTYDownlinks().MarkReservedAsQueued( ownerCtx, uint64(dl.QueID), //nolint:gosec // G115: QueID is always positive (DB-assigned sequence) ownerTenantID, @@ -145,15 +233,11 @@ func (d *downlinkDispatcher) DispatchIfAvailable( nil, // packetCnt - set when the dlDataRes transmission result arrives nil, // orgID - BSSCI uses tenant-level isolation ); err != nil { + // The send happened: report dispatched, leave the row reserved. The + // dlDataQueRsp handler repeats the idempotent confirmation. d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherMarkSentFailed, "queId", dl.QueID, "error", err) - return false, nil - } - - // 5. Commit transaction - if err := tx.Commit(); err != nil { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherTxCommitFailed, "error", err) - return false, nil + return true, nil } d.logger.InfoContext(ownerCtx, bssci.LogDispatcherSuccess, diff --git a/KC-Core/internal/services/bssci/downlink_dispatcher_test.go b/KC-Core/internal/services/bssci/downlink_dispatcher_test.go index bc943e1..57c67a3 100644 --- a/KC-Core/internal/services/bssci/downlink_dispatcher_test.go +++ b/KC-Core/internal/services/bssci/downlink_dispatcher_test.go @@ -3,6 +3,7 @@ package bssciservices import ( "context" "errors" + "fmt" "testing" "time" @@ -39,8 +40,11 @@ type mockTransactionForDispatch struct { } func (m *mockTransactionForDispatch) Commit() error { + if m.commitErr != nil { + return m.commitErr + } m.committed = true - return m.commitErr + return nil } func (m *mockTransactionForDispatch) Rollback() error { @@ -93,9 +97,12 @@ func (m *mockTransactionForDispatch) SCACIOperations() interfaces.SCACIOperation } func (m *mockTransactionForDispatch) DownlinkQueueReader() interfaces.DownlinkQueueReader { return nil } -// mockStorageForDispatch implements interfaces.Storage for dispatcher tests +// mockStorageForDispatch implements interfaces.Storage for dispatcher tests. +// MIOTYDownlinks() exposes the same repository the transaction wraps, matching +// production where the regular repository handles the post-send confirmation. type mockStorageForDispatch struct { tx *mockTransactionForDispatch + dlRepo *mockMIOTYDownlinksForDispatch beginErr error } @@ -126,8 +133,10 @@ func (m *mockStorageForDispatch) RoamingAgreements() interfaces.RoamingAgreement func (m *mockStorageForDispatch) PendingOperations() interfaces.PendingOperationRepository { return nil } -func (m *mockStorageForDispatch) MIOTYMessages() interfaces.MIOTYMessageRepository { return nil } -func (m *mockStorageForDispatch) MIOTYDownlinks() interfaces.MIOTYDownlinkRepository { return nil } +func (m *mockStorageForDispatch) MIOTYMessages() interfaces.MIOTYMessageRepository { return nil } +func (m *mockStorageForDispatch) MIOTYDownlinks() interfaces.MIOTYDownlinkRepository { + return m.dlRepo +} func (m *mockStorageForDispatch) MIOTYBaseStationStatus() interfaces.MIOTYBaseStationStatusRepository { return nil } @@ -147,12 +156,25 @@ func (m *mockStorageForDispatch) SCACISessions() interfaces.SCACISessionReposito func (m *mockStorageForDispatch) SCACIOperations() interfaces.SCACIOperationRepository { return nil } func (m *mockStorageForDispatch) DownlinkQueueReader() interfaces.DownlinkQueueReader { return nil } -// mockMIOTYDownlinksForDispatch implements transaction-specific methods for testing +// reserveByQueueCall captures ReservePendingDownlinkByQueueID arguments +type reserveByQueueCall struct { + tenantID int64 + orgID *uuid.UUID + queueID uint64 + epEUI []byte + bsEUI uint64 +} + +// mockMIOTYDownlinksForDispatch implements dispatch-specific methods for testing type mockMIOTYDownlinksForDispatch struct { - reserveResult *storage.DownlinkMessage - reserveErr error - markQueuedErr error - markQueuedCalls int + reserveResult *storage.DownlinkMessage + reserveErr error + reserveByQueueResult *storage.DownlinkMessage + reserveByQueueErr error + reserveByQueueCalls []reserveByQueueCall + markQueuedErr error + markQueuedCalls int + statusUpdates []string // captured UpdateDownlinkStatus statuses } // orgID parameter enables organization-scoped reservation @@ -163,6 +185,16 @@ func (m *mockMIOTYDownlinksForDispatch) ReserveNextPendingDownlink(_ context.Con return m.reserveResult, nil } +func (m *mockMIOTYDownlinksForDispatch) ReservePendingDownlinkByQueueID(_ context.Context, tenantID int64, orgID *uuid.UUID, queueID uint64, epEUI []byte, bsEUI uint64) (*storage.DownlinkMessage, error) { + m.reserveByQueueCalls = append(m.reserveByQueueCalls, reserveByQueueCall{ + tenantID: tenantID, orgID: orgID, queueID: queueID, epEUI: epEUI, bsEUI: bsEUI, + }) + if m.reserveByQueueErr != nil { + return nil, m.reserveByQueueErr + } + return m.reserveByQueueResult, nil +} + // orgID parameter enables organization-scoped queue marking func (m *mockMIOTYDownlinksForDispatch) MarkReservedAsQueued(_ context.Context, _ uint64, _ int64, _ uint64, _ int64, _ *uint32, _ *uuid.UUID) error { m.markQueuedCalls++ @@ -187,8 +219,9 @@ func (m *mockMIOTYDownlinksForDispatch) EnqueueDownlink(_ context.Context, _ *st return nil, nil } -// orgID parameter enables organization-scoped status updates -func (m *mockMIOTYDownlinksForDispatch) UpdateDownlinkStatus(_ context.Context, _ string, _ string, _ *uuid.UUID) error { +// UpdateDownlinkStatus captures release-to-pending calls +func (m *mockMIOTYDownlinksForDispatch) UpdateDownlinkStatus(_ context.Context, _ string, status string, _ *uuid.UUID) error { + m.statusUpdates = append(m.statusUpdates, status) return nil } @@ -205,53 +238,58 @@ func (m *mockMIOTYDownlinksForDispatch) RevokeDownlink(_ context.Context, _ int6 // mockSendFn tracks calls to SendDLDataQueue type mockSendFn struct { - calls int - err error + calls int + err error + dlRxStatQry []bool // captured dlRxStatQry flag per call + // txCommittedAtSend records whether the reservation transaction had been + // committed at the moment the wire send ran + tx *mockTransactionForDispatch + txCommittedAtSend []bool } -func (m *mockSendFn) Send(_ string, _ uint64, _ [][]byte, _ int64, _ float32, _ bool, _ []int64, _ uint8, _, _, _, _ bool, _ int64) error { +func (m *mockSendFn) Send(_ string, _ uint64, _ [][]byte, _ int64, _ float32, _ bool, _ []int64, _ uint8, _, _, _, _ bool, _ int64, dlRxStatQry bool) error { m.calls++ + m.dlRxStatQry = append(m.dlRxStatQry, dlRxStatQry) + if m.tx != nil { + m.txCommittedAtSend = append(m.txCommittedAtSend, m.tx.committed) + } return m.err } -func TestDispatchIfAvailable_Success(t *testing.T) { +func newDispatchFixture(dl *storage.DownlinkMessage) (*mockMIOTYDownlinksForDispatch, *mockTransactionForDispatch, *mockStorageForDispatch, *mockSendFn) { dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: &storage.DownlinkMessage{ - QueID: 12345, - Payload: []byte("test payload"), - Priority: 1.0, - Format: 0, - ResponseExp: true, - }, - } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, + reserveResult: dl, + reserveByQueueResult: dl, } - storageM := &mockStorageForDispatch{tx: tx} - sendFn := &mockSendFn{} - - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) + tx := &mockTransactionForDispatch{miotyDownlinks: dlRepo} + storageM := &mockStorageForDispatch{tx: tx, dlRepo: dlRepo} + sendFn := &mockSendFn{tx: tx} + return dlRepo, tx, storageM, sendFn +} - session := &bssci.Session{ +func testDispatchSession() *bssci.Session { + return &bssci.Session{ ProtocolSessionState: bssci.ProtocolSessionState{ ID: "test-session-123", BaseStationEUI: 0x1234567890ABCDEF, }, } +} + +func TestDispatchIfAvailable_Success(t *testing.T) { + dlRepo, tx, storageM, sendFn := newDispatchFixture(&storage.DownlinkMessage{ + QueID: 12345, + Payload: []byte("test payload"), + Priority: 1.0, + Format: 0, + ResponseExp: true, + }) + + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, // ownerTenantID - uuid.New(), // ownerOrgUUID - session, - 0xAABBCCDDEEFF0011, // epEUI - true, // responseExp - false, // dlAck - ) + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -266,41 +304,42 @@ func TestDispatchIfAvailable_Success(t *testing.T) { t.Errorf("expected 1 markQueued call, got %d", dlRepo.markQueuedCalls) } if !tx.committed { - t.Error("expected transaction to be committed") + t.Error("expected reservation transaction to be committed") } -} - -func TestDispatchIfAvailable_NoPendingDownlinks(t *testing.T) { - dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: nil, // No pending downlinks + // The reservation transaction must be closed before any wire write + if len(sendFn.txCommittedAtSend) != 1 || !sendFn.txCommittedAtSend[0] { + t.Error("expected reservation transaction committed BEFORE the wire send") } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, - } - storageM := &mockStorageForDispatch{tx: tx} - sendFn := &mockSendFn{} +} - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) +func TestDispatchIfAvailable_DlRxStatQryPassthrough(t *testing.T) { + for _, want := range []bool{true, false} { + _, _, storageM, sendFn := newDispatchFixture(&storage.DownlinkMessage{ + QueID: 7, + Payload: []byte("p"), + DlRxStatQry: want, + }) + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) - session := &bssci.Session{ - ProtocolSessionState: bssci.ProtocolSessionState{ - ID: "test-session", - }, + dispatched, err := dispatcher.DispatchIfAvailable( + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, false, false) + if err != nil || !dispatched { + t.Fatalf("dlRxStatQry=%v: unexpected result dispatched=%v err=%v", want, dispatched, err) + } + if len(sendFn.dlRxStatQry) != 1 || sendFn.dlRxStatQry[0] != want { + t.Errorf("expected sendFn dlRxStatQry=%v, got %v", want, sendFn.dlRxStatQry) + } } +} + +func TestDispatchIfAvailable_NoPendingDownlinks(t *testing.T) { + _, tx, storageM, sendFn := newDispatchFixture(nil) + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -311,33 +350,19 @@ func TestDispatchIfAvailable_NoPendingDownlinks(t *testing.T) { if sendFn.calls != 0 { t.Errorf("expected 0 sendFn calls, got %d", sendFn.calls) } + if !tx.rolledBack { + t.Error("expected empty reservation transaction to be rolled back") + } } func TestDispatchIfAvailable_NoTenantContext(t *testing.T) { storageM := &mockStorageForDispatch{} sendFn := &mockSendFn{} - - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) - - session := &bssci.Session{ - ProtocolSessionState: bssci.ProtocolSessionState{ - ID: "test-session", - }, - } + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 0, // Zero tenant ID - should skip - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) + context.Background(), 0, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -351,32 +376,13 @@ func TestDispatchIfAvailable_NoTenantContext(t *testing.T) { } func TestDispatchIfAvailable_TransactionBeginError(t *testing.T) { - storageM := &mockStorageForDispatch{ - beginErr: errors.New("database connection failed"), - } + storageM := &mockStorageForDispatch{beginErr: errors.New("database connection failed")} sendFn := &mockSendFn{} - - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) - - session := &bssci.Session{ - ProtocolSessionState: bssci.ProtocolSessionState{ - ID: "test-session", - }, - } + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) // Should gracefully degrade, not return error (don't fail uplink) if err != nil { @@ -387,195 +393,130 @@ func TestDispatchIfAvailable_TransactionBeginError(t *testing.T) { } } -func TestDispatchIfAvailable_SendFunctionError(t *testing.T) { - dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: &storage.DownlinkMessage{ - QueID: 12345, - Payload: []byte("test"), - Priority: 1.0, - }, +func TestDispatchIfAvailable_ReservationCommitError(t *testing.T) { + _, tx, storageM, sendFn := newDispatchFixture(&storage.DownlinkMessage{ + QueID: 12345, Payload: []byte("test"), Priority: 1.0, + }) + tx.commitErr = errors.New("commit failed") + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) + + dispatched, err := dispatcher.DispatchIfAvailable( + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) + + if err != nil { + t.Fatalf("unexpected error: %v", err) } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, + if dispatched { + t.Error("expected dispatched=false on reservation commit error") } - storageM := &mockStorageForDispatch{tx: tx} - sendFn := &mockSendFn{ - err: errors.New("send failed"), + // Nothing may reach the wire when the reservation never became durable + if sendFn.calls != 0 { + t.Errorf("expected 0 sendFn calls, got %d", sendFn.calls) } +} - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) - - session := &bssci.Session{ - ProtocolSessionState: bssci.ProtocolSessionState{ - ID: "test-session", - }, - } +func TestDispatchIfAvailable_SendFunctionError_ReleasesToPending(t *testing.T) { + dlRepo, tx, storageM, sendFn := newDispatchFixture(&storage.DownlinkMessage{ + ID: 9, QueID: 12345, Payload: []byte("test"), Priority: 1.0, + }) + sendFn.err = errors.New("send failed") + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) - - // Should gracefully degrade - if err != nil { - t.Fatalf("unexpected error: %v", err) + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) + + if err == nil { + t.Fatal("expected error on definite send failure") } if dispatched { t.Error("expected dispatched=false on send error") } - // Transaction should have been rolled back (defer) - if tx.committed { - t.Error("expected transaction NOT to be committed on send error") + // Reservation was durable (committed) and the definite pre-write failure + // released the row back to pending for at-least-once retry + if !tx.committed { + t.Error("expected reservation transaction committed before send") } -} - -func TestDispatchIfAvailable_MarkQueuedError(t *testing.T) { - dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: &storage.DownlinkMessage{ - QueID: 12345, - Payload: []byte("test"), - Priority: 1.0, - }, - markQueuedErr: errors.New("mark queued failed"), + if len(dlRepo.statusUpdates) != 1 || dlRepo.statusUpdates[0] != bssci.DLQueueStatusPending { + t.Errorf("expected release to pending, got %v", dlRepo.statusUpdates) } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, + if dlRepo.markQueuedCalls != 0 { + t.Errorf("expected no markQueued call, got %d", dlRepo.markQueuedCalls) } - storageM := &mockStorageForDispatch{tx: tx} - sendFn := &mockSendFn{} +} - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) - - session := &bssci.Session{ - ProtocolSessionState: bssci.ProtocolSessionState{ - ID: "test-session", - }, - } +func TestDispatchIfAvailable_AmbiguousSendError_StaysReserved(t *testing.T) { + dlRepo, _, storageM, sendFn := newDispatchFixture(&storage.DownlinkMessage{ + ID: 9, QueID: 12345, Payload: []byte("test"), Priority: 1.0, + }) + sendFn.err = fmt.Errorf("write payload: %w", bssci.ErrAmbiguousWrite) + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) - - // Should gracefully degrade - if err != nil { - t.Fatalf("unexpected error: %v", err) + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) + + if !errors.Is(err, bssci.ErrAmbiguousWrite) { + t.Fatalf("expected ambiguous-write error, got %v", err) } if dispatched { - t.Error("expected dispatched=false on mark queued error") + t.Error("expected dispatched=false on ambiguous send") } - // Transaction should NOT be committed on mark queued error - if tx.committed { - t.Error("expected transaction NOT to be committed on mark queued error") + // An uncertain send must never return the row to plain pending: a retry + // would mint a replacement operation ID for a possibly delivered frame + if len(dlRepo.statusUpdates) != 0 { + t.Errorf("expected row to stay reserved, got status updates %v", dlRepo.statusUpdates) } -} - -func TestDispatchIfAvailable_CommitError(t *testing.T) { - dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: &storage.DownlinkMessage{ - QueID: 12345, - Payload: []byte("test"), - Priority: 1.0, - }, - } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, - commitErr: errors.New("commit failed"), + if dlRepo.markQueuedCalls != 0 { + t.Errorf("expected no markQueued call, got %d", dlRepo.markQueuedCalls) } - storageM := &mockStorageForDispatch{tx: tx} - sendFn := &mockSendFn{} - - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) +} - session := &bssci.Session{ - ProtocolSessionState: bssci.ProtocolSessionState{ - ID: "test-session", - }, - } +func TestDispatchIfAvailable_MarkQueuedError_ReportsDispatched(t *testing.T) { + dlRepo, _, storageM, sendFn := newDispatchFixture(&storage.DownlinkMessage{ + QueID: 12345, Payload: []byte("test"), Priority: 1.0, + }) + dlRepo.markQueuedErr = errors.New("mark queued failed") + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) - - // Should gracefully degrade + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) + + // The send happened: the dispatch is reported and the row stays reserved + // until the idempotent dlDataQueRsp confirmation repairs it if err != nil { t.Fatalf("unexpected error: %v", err) } - if dispatched { - t.Error("expected dispatched=false on commit error") + if !dispatched { + t.Error("expected dispatched=true when the send succeeded") + } + if len(dlRepo.statusUpdates) != 0 { + t.Errorf("expected no release, got status updates %v", dlRepo.statusUpdates) } } func TestDispatchIfAvailable_UsesUserDataIfPresent(t *testing.T) { userData := [][]byte{[]byte("packet1"), []byte("packet2")} - dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: &storage.DownlinkMessage{ - QueID: 12345, - Payload: []byte("should be ignored"), - UserData: userData, - Priority: 1.0, - }, - } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, - } - storageM := &mockStorageForDispatch{tx: tx} + _, _, storageM, _ := newDispatchFixture(&storage.DownlinkMessage{ + QueID: 12345, + Payload: []byte("should be ignored"), + UserData: userData, + Priority: 1.0, + }) var capturedPayloads [][]byte - sendFn := func(_ string, _ uint64, payloads [][]byte, _ int64, _ float32, _ bool, _ []int64, _ uint8, _, _, _, _ bool, _ int64) error { + sendFn := func(_ string, _ uint64, payloads [][]byte, _ int64, _ float32, _ bool, _ []int64, _ uint8, _, _, _, _ bool, _ int64, _ bool) error { capturedPayloads = payloads return nil } - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn, - ) - - session := &bssci.Session{ - ProtocolSessionState: bssci.ProtocolSessionState{ - ID: "test-session", - }, - } + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) + context.Background(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -592,3 +533,74 @@ func TestDispatchIfAvailable_UsesUserDataIfPresent(t *testing.T) { t.Errorf("expected first payload 'packet1', got '%s'", string(capturedPayloads[0])) } } + +func TestDispatchQueue_Success(t *testing.T) { + dlRepo, _, storageM, sendFn := newDispatchFixture(&storage.DownlinkMessage{ + QueID: 777, Payload: []byte("test"), Priority: 1.0, DlRxStatQry: true, + }) + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) + + dispatched, err := dispatcher.DispatchQueue( + context.Background(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !dispatched { + t.Error("expected dispatched=true") + } + if sendFn.calls != 1 || !sendFn.dlRxStatQry[0] { + t.Errorf("expected 1 send with dlRxStatQry=true, got calls=%d flags=%v", sendFn.calls, sendFn.dlRxStatQry) + } + if dlRepo.markQueuedCalls != 1 { + t.Errorf("expected 1 markQueued call, got %d", dlRepo.markQueuedCalls) + } + // Exact-match reservation arguments + if len(dlRepo.reserveByQueueCalls) != 1 { + t.Fatalf("expected 1 reserve-by-queue call, got %d", len(dlRepo.reserveByQueueCalls)) + } + call := dlRepo.reserveByQueueCalls[0] + if call.tenantID != 42 || call.queueID != 777 || call.bsEUI != 0x1234567890ABCDEF { + t.Errorf("unexpected reservation args: %+v", call) + } +} + +func TestDispatchQueue_NoMatchingPendingRow(t *testing.T) { + dlRepo, _, storageM, sendFn := newDispatchFixture(nil) + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) + + dispatched, err := dispatcher.DispatchQueue( + context.Background(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dispatched { + t.Error("expected dispatched=false when no matching pending row") + } + if sendFn.calls != 0 { + t.Errorf("expected 0 sendFn calls, got %d", sendFn.calls) + } + if dlRepo.markQueuedCalls != 0 { + t.Errorf("expected 0 markQueued calls, got %d", dlRepo.markQueuedCalls) + } +} + +func TestDispatchQueue_ReservationError(t *testing.T) { + dlRepo, _, storageM, sendFn := newDispatchFixture(nil) + dlRepo.reserveByQueueErr = errors.New("db down") + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) + + dispatched, err := dispatcher.DispatchQueue( + context.Background(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) + + if err == nil { + t.Fatal("expected reservation error to propagate") + } + if dispatched { + t.Error("expected dispatched=false on reservation error") + } + if sendFn.calls != 0 { + t.Errorf("expected 0 sendFn calls, got %d", sendFn.calls) + } +} diff --git a/KC-Core/internal/services/bssci/session_resume_db_test.go b/KC-Core/internal/services/bssci/session_resume_db_test.go index 2332186..3278d90 100644 --- a/KC-Core/internal/services/bssci/session_resume_db_test.go +++ b/KC-Core/internal/services/bssci/session_resume_db_test.go @@ -481,3 +481,30 @@ func TestHandleResume_VersionIncompatible(t *testing.T) { assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) require.NotNil(t, outcome.Previous, "the stale session is returned for termination") } + +// TestHydrateSessionNullVersionStaysEmpty verifies a legacy NULL +// protocol_version hydrates as empty rather than a fabricated version: +// resumeVersionCompatible treats empty as "cannot assert incompatibility" and +// the resume activation backfills the newly selected version. +func TestHydrateSessionNullVersionStaysEmpty(t *testing.T) { + svc, _ := newResumeTestService(t, 700) + + dbSession := &models.BaseStationSession{ + ID: 46, + CanResume: true, + Encoding: "json", + BaseStationID: 4, + TenantID: 700, + Status: models.SessionStatusDisconnected, + StartedAt: time.Now(), + // ProtocolVersion deliberately nil (legacy row) + } + + result := svc.hydrateSessionFromDB(dbSession, 0x1234567890ABCDEF) + + assert.Empty(t, result.NegotiatedVersion, + "a NULL persisted version must stay empty until compatibility evaluation") + assert.Empty(t, result.ClientVersion) + assert.True(t, resumeVersionCompatible(result.NegotiatedVersion, "1.0.0"), + "an empty persisted version cannot assert incompatibility") +} diff --git a/KC-Core/internal/services/bssci/session_service.go b/KC-Core/internal/services/bssci/session_service.go index 94302da..d226354 100644 --- a/KC-Core/internal/services/bssci/session_service.go +++ b/KC-Core/internal/services/bssci/session_service.go @@ -14,7 +14,6 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" - "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/google/uuid" ) @@ -192,15 +191,14 @@ func (s *sessionService) hydrateSessionFromDB(dbSession *models.BaseStationSessi orgID = uuid.Nil // Safe default when pointer is nil } - // Handle ProtocolVersion hydration (BSSCI §4-4.5) + // Handle ProtocolVersion hydration (BSSCI §4-4.5). A legacy NULL stays + // empty: resumeVersionCompatible treats it as "cannot assert + // incompatibility" and the resume activation persists the newly selected + // version, backfilling the row instead of fabricating a version here. var negotiatedVersion, clientVersion string if dbSession.ProtocolVersion != nil { negotiatedVersion = *dbSession.ProtocolVersion clientVersion = *dbSession.ProtocolVersion // Initially same as negotiated - } else { - // Fallback for old sessions without stored version - negotiatedVersion = mioty.MIOTYProtocolVersion - clientVersion = mioty.MIOTYProtocolVersion } return &bssci.Session{ diff --git a/KC-Core/internal/services/bssci/status_service.go b/KC-Core/internal/services/bssci/status_service.go index d1b0149..6b82f7c 100644 --- a/KC-Core/internal/services/bssci/status_service.go +++ b/KC-Core/internal/services/bssci/status_service.go @@ -83,6 +83,71 @@ func (s *statusService) RecordPendingOperation(ctx context.Context, session *bss return nil } +// RecordPendingOperations durably records several operations in one repository +// transaction and mirrors them into the cache only after the transaction +// commits, so a multi-frame sequence never has partially persisted recovery +// state. +func (s *statusService) RecordPendingOperations(ctx context.Context, session *bssci.Session, ops []*bssci.PendingOperation, dbSessionID int64) error { + reqs := make([]*interfaces.PendingOperationRequest, 0, len(ops)) + for _, op := range ops { + operationData, err := json.Marshal(op.Message) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to marshal pending operation", + "error", err, + "opId", op.OperationID) + return err + } + + var metadataJSON json.RawMessage + if op.Metadata != nil { + metadataBytes, err := json.Marshal(op.Metadata) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to marshal pending operation metadata", + "error", err, + "opId", op.OperationID) + return err + } + metadataJSON = json.RawMessage(metadataBytes) + } + + reqs = append(reqs, &interfaces.PendingOperationRequest{ + SessionID: dbSessionID, + OperationID: op.OperationID, + OperationType: op.OperationType, + EndpointEUI: op.Endpoint, + OperationData: json.RawMessage(operationData), + Metadata: metadataJSON, + }) + } + + if err := s.repo.CreateBatch(ctx, reqs); err != nil { + return err + } + + s.mu.Lock() + for _, op := range ops { + key := bssci.SessionOpKey{ + SessionID: session.ID, + OperationID: op.OperationID, + } + (*s.pendingOps)[key] = op + } + s.mu.Unlock() + return nil +} + +// RestorePendingOperation hydrates the cache from an already authoritative DB +// row without writing it back (session resume path). +func (s *statusService) RestorePendingOperation(session *bssci.Session, opId int64, op *bssci.PendingOperation) { + key := bssci.SessionOpKey{ + SessionID: session.ID, + OperationID: opId, + } + s.mu.Lock() + (*s.pendingOps)[key] = op + s.mu.Unlock() +} + // GetPendingOperation retrieves operation from REAL map using SessionOpKey composite key func (s *statusService) GetPendingOperation(session *bssci.Session, opId int64) (*bssci.PendingOperation, error) { key := bssci.SessionOpKey{ @@ -172,27 +237,3 @@ func (s *statusService) ExtractQueueMetadata(session *bssci.Session, opId int64) return endpointEUI, queueID, tenantID } - -// CleanupPendingOp removes pending operation from in-memory map only using SessionOpKey. -// Uses SessionOpKey composite key for multi-session support (BSSCI §5.11-5.12.3). -// -// This method provides controlled access to the pendingOps map for cleanup operations -// where DB persistence is not required or has already been handled separately. -// -// For complete cleanup (map + DB), use RemovePendingOperation instead. -// -// Parameters: -// - session: Session containing SessionID for composite key construction -// - opId: Operation ID to remove from map -// -// Thread Safety: Method handles mutex locking internally -func (s *statusService) CleanupPendingOp(session *bssci.Session, opId int64) { - key := bssci.SessionOpKey{ - SessionID: session.ID, - OperationID: opId, - } - - s.mu.Lock() - delete(*s.pendingOps, key) - s.mu.Unlock() -} diff --git a/KC-Core/internal/services/bssci/status_service_integration_test.go b/KC-Core/internal/services/bssci/status_service_integration_test.go index 5f3c0c4..8c96170 100644 --- a/KC-Core/internal/services/bssci/status_service_integration_test.go +++ b/KC-Core/internal/services/bssci/status_service_integration_test.go @@ -26,6 +26,13 @@ func (m *mockPendingOperationRepositoryWithCallTracking) Create(_ context.Contex return nil } +func (m *mockPendingOperationRepositoryWithCallTracking) CreateBatch(_ context.Context, reqs []*interfaces.PendingOperationRequest) error { + m.mu.Lock() + m.createCallCount += len(reqs) + m.mu.Unlock() + return nil +} + func (m *mockPendingOperationRepositoryWithCallTracking) UpdateMetadata(_ context.Context, _ int64, _ int64, _ json.RawMessage) error { return nil } diff --git a/KC-Core/internal/services/bssci/test_helpers.go b/KC-Core/internal/services/bssci/test_helpers.go index a4ec236..3b59d94 100644 --- a/KC-Core/internal/services/bssci/test_helpers.go +++ b/KC-Core/internal/services/bssci/test_helpers.go @@ -400,6 +400,10 @@ func (m *mockPendingOperationRepository) Create(_ context.Context, _ *interfaces return nil } +func (m *mockPendingOperationRepository) CreateBatch(_ context.Context, _ []*interfaces.PendingOperationRequest) error { + return nil +} + func (m *mockPendingOperationRepository) UpdateMetadata(_ context.Context, _ int64, _ int64, _ json.RawMessage) error { return nil } @@ -617,6 +621,10 @@ func (m *mockMIOTYDownlinkRepository) ReserveNextPendingDownlink(_ context.Conte return nil, nil // No pending downlinks in basic mock } +func (m *mockMIOTYDownlinkRepository) ReservePendingDownlinkByQueueID(_ context.Context, _ int64, _ *uuid.UUID, _ uint64, _ []byte, _ uint64) (*storage.DownlinkMessage, error) { + return nil, nil // No pending downlinks in basic mock +} + // orgID parameter enables organization-scoped queue marking func (m *mockMIOTYDownlinkRepository) MarkReservedAsQueued(_ context.Context, _ uint64, _ int64, _ uint64, _ int64, _ *uint32, _ *uuid.UUID) error { return nil // No-op in basic mock diff --git a/KC-Core/internal/services/scaci/dl_service.go b/KC-Core/internal/services/scaci/dl_service.go index 4d2b2a8..55d3871 100644 --- a/KC-Core/internal/services/scaci/dl_service.go +++ b/KC-Core/internal/services/scaci/dl_service.go @@ -123,7 +123,7 @@ func (dls *dlService) QueueDownlink( // Delegate to BSSCI scheduler // IMPORTANT: queuedQueId may differ from req.QueId if BSSCI normalizes it - queuedQueId, bsEui, err := dls.dlScheduler.QueueDownlink(req, tenantID) + queuedQueId, bsEui, err := dls.dlScheduler.QueueDownlink(ctx, req, tenantID) if err != nil { dls.logger.ErrorContext(ctx, scaci.LogSCACIEnqueueDownlinkFailed, diff --git a/KC-Core/internal/services/scaci/dl_service_test.go b/KC-Core/internal/services/scaci/dl_service_test.go index 4887c17..cf5d220 100644 --- a/KC-Core/internal/services/scaci/dl_service_test.go +++ b/KC-Core/internal/services/scaci/dl_service_test.go @@ -22,7 +22,7 @@ type mockDownlinkScheduler struct { mock.Mock } -func (m *mockDownlinkScheduler) QueueDownlink(req *mioty.DLDataQueue, tenantID int64) (uint64, uint64, error) { +func (m *mockDownlinkScheduler) QueueDownlink(_ context.Context, req *mioty.DLDataQueue, tenantID int64) (uint64, uint64, error) { args := m.Called(req, tenantID) return args.Get(0).(uint64), args.Get(1).(uint64), args.Error(2) } diff --git a/KC-Core/pkg/bssci/attach_propagate_integration_test.go b/KC-Core/pkg/bssci/attach_propagate_integration_test.go index 25ff481..4d04b8d 100644 --- a/KC-Core/pkg/bssci/attach_propagate_integration_test.go +++ b/KC-Core/pkg/bssci/attach_propagate_integration_test.go @@ -334,6 +334,15 @@ type attPrpCapturingPendingOps struct { func (r *attPrpCapturingPendingOps) Create(_ context.Context, _ *interfaces.PendingOperationRequest) error { return nil } + +func (r *attPrpCapturingPendingOps) CreateBatch(ctx context.Context, reqs []*interfaces.PendingOperationRequest) error { + for _, req := range reqs { + if err := r.Create(ctx, req); err != nil { + return err + } + } + return nil +} func (r *attPrpCapturingPendingOps) UpdateMetadata(_ context.Context, sessionID, operationID int64, metadata json.RawMessage) error { r.mu.Lock() defer r.mu.Unlock() diff --git a/KC-Core/pkg/bssci/connect_failure_test.go b/KC-Core/pkg/bssci/connect_failure_test.go new file mode 100644 index 0000000..08d9a67 --- /dev/null +++ b/KC-Core/pkg/bssci/connect_failure_test.go @@ -0,0 +1,117 @@ +package bssci + +import ( + "context" + "errors" + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// failingRegistrationConnSvc resolves the base station but fails the live +// connection registration, exercising the activation compensation path. +type failingRegistrationConnSvc struct{} + +func (failingRegistrationConnSvc) GetBaseStation(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { + return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Test BS"}, nil +} + +func (failingRegistrationConnSvc) GetBaseStationGlobal(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { + return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Test BS"}, nil +} + +func (failingRegistrationConnSvc) RegisterConnection(_ context.Context, _ *Session, _ *basestation.BaseStation, _ *basestation.ConnectionManager) error { + return errors.New("registration failed") +} + +// terminateSpySessionSvc records TerminateSession calls to prove the +// activation compensation runs. +type terminateSpySessionSvc struct { + SessionService + terminated int +} + +func (t *terminateSpySessionSvc) TerminateSession(ctx context.Context, session *Session) error { + t.terminated++ + return t.SessionService.TerminateSession(ctx, session) +} + +// TestConnectResponseWriteFailureNoActivation: a conRsp write failure makes +// the connect operation Terminal without any partial activation - the session +// is never published to the live registry. +func TestConnectResponseWriteFailureNoActivation(t *testing.T) { + log := newRecordingLogger() + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, storage := CreateTestServices(log, nil) + server := NewTestServer(log, storage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, failingRegistrationConnSvc{}, broadcaster, + queueSerializer, auditLogger, tenantResolver) + server.config = &Config{MessageEncoding: EncodingJSON, ServiceCenterEUI: TestBsEui02, + Vendor: "v", Model: "m", Name: "n", SoftwareVersion: "1.0.0"} + server.RegisterHandlers() + + conn := &bsscitest.TestConn{Encoding: EncodingJSON, FailWrites: true} + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "conrsp-write-failure", + Encoding: EncodingJSON, + ConnectState: ConnectStateAwaitingConnect, + }, + Conn: conn, + } + + payload := connectPayload("1.0.0", uint64(TestBsEui01)) + msg := &Message{Command: payload["command"].(string), OpId: 0, Data: payload} + + err := server.CallHandleConnect(session, msg, payload) + + require.Error(t, err, "a conRsp write failure must surface as a handler error (closing the connection)") + assert.Equal(t, ConnectStateTerminal, session.ConnectState, + "the connect operation is Terminal after a conRsp write failure") + assert.Nil(t, server.GetSession(session.ID), + "a session whose conRsp never went out must not be published to the live registry") +} + +// TestActivationCompensationOnRegistrationFailure: when the live connection +// registration fails after the session row was persisted, the persisted +// session is compensated (terminated) and nothing is published to the live +// registries. +func TestActivationCompensationOnRegistrationFailure(t *testing.T) { + log := newRecordingLogger() + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, storage := CreateTestServices(log, nil) + spy := &terminateSpySessionSvc{SessionService: sessionSvc} + server := NewTestServer(log, storage, nil, 1, + spy, downlinkSvc, statusSvc, failingRegistrationConnSvc{}, broadcaster, + queueSerializer, auditLogger, tenantResolver) + server.config = &Config{MessageEncoding: EncodingJSON} + server.RegisterHandlers() + + conn := &bsscitest.TestConn{Encoding: EncodingJSON} + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "activation-compensation", + BaseStationEUI: TestBsEui01, + Encoding: EncodingJSON, + ResolvedTenantID: 1, + ConnectState: ConnectStateAwaitingConnectComplete, + }, + Conn: conn, + pendingBaseStation: &basestation.BaseStation{ + ID: 1, TenantID: 1, Name: "Test BS", + }, + } + + data := map[string]interface{}{"command": "conCmp", "opId": int64(0)} + msg := &Message{Command: "conCmp", OpId: 0, Data: data} + + err := server.CallHandleConnectComplete(session, msg, data) + + require.Error(t, err, "a registration failure after conCmp must close the connection") + assert.Equal(t, 1, spy.terminated, + "the just-persisted session must be compensated via TerminateSession") + assert.Equal(t, ConnectStateTerminal, session.ConnectState) + assert.Nil(t, server.GetSession(session.ID), + "a session whose activation failed must not be published to the live registry") +} diff --git a/KC-Core/pkg/bssci/constants.go b/KC-Core/pkg/bssci/constants.go index f1c6bd3..5702cea 100644 --- a/KC-Core/pkg/bssci/constants.go +++ b/KC-Core/pkg/bssci/constants.go @@ -304,7 +304,7 @@ const ( const ( DLQueueStatusPending = "pending" // Queued awaiting scheduler processing DLQueueStatusScheduled = "scheduled" // Scheduler selected for transmission - DLQueueStatusReserved = "reserved" // Reserved for auto-dispatch (transient, within transaction) + DLQueueStatusReserved = "reserved" // Durably reserved for dispatch; confirmed queued after the wire send DLQueueStatusQueued = "queued" // Sent to BS via dlDataQue, awaiting transmission DLQueueStatusTransmitted = "transmitted" // BS reported successful transmission via dlDataRes (BSSCI 5.14) DLQueueStatusDelivered = "delivered" // Endpoint acknowledged receipt (if ack requested) diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index f2989b9..edf4c9a 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -129,6 +129,17 @@ type StatusService interface { // RecordPendingOperation stores in map + DB using SessionOpKey composite key RecordPendingOperation(ctx context.Context, session *Session, opId int64, op *PendingOperation, dbSessionID int64) error + // RecordPendingOperations durably records several operations in one + // repository transaction (all-or-nothing) and mirrors them into the cache + // only after the transaction commits. Used for multi-frame sequences such + // as the dlRxStatQry/dlDataQue pair whose recovery records must never be + // partially persisted. + RecordPendingOperations(ctx context.Context, session *Session, ops []*PendingOperation, dbSessionID int64) error + + // RestorePendingOperation hydrates the in-memory cache from an already + // authoritative DB row without writing it back (session resume path). + RestorePendingOperation(session *Session, opId int64, op *PendingOperation) + // GetPendingOperation retrieves from map using SessionOpKey composite key GetPendingOperation(session *Session, opId int64) (*PendingOperation, error) @@ -140,11 +151,6 @@ type StatusService interface { // Returns tenantID for proper roaming tenant isolation (BSSCI §5.12). // Session parameter required for SessionOpKey composite key lookup. ExtractQueueMetadata(session *Session, opId int64) (endpointEUI uint64, queueID int64, tenantID string) - - // CleanupPendingOp removes pending operation from in-memory map only using SessionOpKey. - // Enables handlers to clean up map without direct mutex access. - // DB cleanup should use RemovePendingOperation for complete cleanup. - CleanupPendingOp(session *Session, opId int64) } // ConnectionService wraps REAL basestation.ConnectionManager operations @@ -221,9 +227,13 @@ type EPStatusData struct { // DownlinkCommander sends downlink commands to base stations type DownlinkCommander interface { + // SendDLDataQueue queues downlink data; dlRxStatQry pairs a BSSCI + // dlRxStatQry operation (rev1 §5.16 / classic §3.16) ahead of the queue + // frame per the SCACI §3.10.1 hint. SendDLDataQueue(sessionID string, epEui uint64, payloads [][]byte, queId int64, prio float32, cntDepend bool, packetCnt []int64, format uint8, - responseExp bool, responsePrio bool, dlWindReq bool, expOnly bool, tenantID int64) error + responseExp bool, responsePrio bool, dlWindReq bool, expOnly bool, tenantID int64, + dlRxStatQry bool) error SendDLDataRevoke(sessionID string, epEui uint64, queId uint64) error SendDLRXStatusQuery(sessionID string, epEui uint64) error } @@ -718,6 +728,21 @@ type DownlinkDispatcher interface { responseExp bool, dlAck bool, ) (dispatched bool, err error) + + // DispatchQueue reserves one exact pending queue row (by queue ID, tenant, + // and endpoint EUI) and dispatches it over the given session. Used for + // SCACI-initiated immediate delivery (SCACI §3.10.1) so both delivery + // paths share the dispatcher's pending→reserved→queued lifecycle. + // Returns dispatched=false with nil error when no matching pending row + // exists (already dispatched, revoked, or foreign). + DispatchQueue( + ownerCtx context.Context, + ownerTenantID int64, + ownerOrgUUID uuid.UUID, + session *Session, + queueID uint64, + epEUI uint64, + ) (dispatched bool, err error) } // BlueprintDecoder decodes MIOTY payloads using blueprint definitions (MIOTY App Layer Spec) diff --git a/KC-Core/pkg/bssci/detach_integration_test.go b/KC-Core/pkg/bssci/detach_integration_test.go index 095b836..fa30b7d 100644 --- a/KC-Core/pkg/bssci/detach_integration_test.go +++ b/KC-Core/pkg/bssci/detach_integration_test.go @@ -537,6 +537,9 @@ type stubPendingOperationRepo struct{} func (stubPendingOperationRepo) Create(context.Context, *interfaces.PendingOperationRequest) error { return nil } +func (stubPendingOperationRepo) CreateBatch(context.Context, []*interfaces.PendingOperationRequest) error { + return nil +} func (stubPendingOperationRepo) UpdateMetadata(context.Context, int64, int64, json.RawMessage) error { return nil } diff --git a/KC-Core/pkg/bssci/detach_propagate_complete_test.go b/KC-Core/pkg/bssci/detach_propagate_complete_test.go index 677631a..1f3ecab 100644 --- a/KC-Core/pkg/bssci/detach_propagate_complete_test.go +++ b/KC-Core/pkg/bssci/detach_propagate_complete_test.go @@ -327,6 +327,10 @@ type detPrpCapturingPendingOps struct { func (r *detPrpCapturingPendingOps) Create(_ context.Context, _ *interfaces.PendingOperationRequest) error { return nil } + +func (r *detPrpCapturingPendingOps) CreateBatch(_ context.Context, _ []*interfaces.PendingOperationRequest) error { + return nil +} func (r *detPrpCapturingPendingOps) UpdateMetadata(_ context.Context, sessionID, operationID int64, metadata json.RawMessage) error { r.mu.Lock() defer r.mu.Unlock() diff --git a/KC-Core/pkg/bssci/dl_queue_pair_test.go b/KC-Core/pkg/bssci/dl_queue_pair_test.go new file mode 100644 index 0000000..c064dae --- /dev/null +++ b/KC-Core/pkg/bssci/dl_queue_pair_test.go @@ -0,0 +1,170 @@ +package bssci + +import ( + "context" + "errors" + "testing" + + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pairDLRXRepo implements the DL RX correlation write used by the +// dlRxStatQry/dlDataQue pair; createErr exercises the pre-write abort path. +type pairDLRXRepo struct { + interfaces.DLRXStatusRepository + createErr error + createCalls int +} + +func (r *pairDLRXRepo) CreateDLRXStatusQuery(_ context.Context, _ int64, _ *uuid.UUID, _, _ []byte, _ int64) error { + r.createCalls++ + return r.createErr +} + +// pairStorage extends the detach-test stubStorage with a working DL RX +// correlation repository. +type pairStorage struct { + *stubStorage + dlrx *pairDLRXRepo +} + +func (s *pairStorage) DLRXStatus() interfaces.DLRXStatusRepository { return s.dlrx } + +// newPairFixture builds a server + registered session for exercising the +// SendDLDataQueue dlRxStatQry pairing (SCACI §3.10.1, BSSCI rev1 §5.16 / +// classic §3.16). +func newPairFixture(t *testing.T) (*Server, *memoryStatusService, *pairDLRXRepo, *Session, *bsscitest.TestConn) { + t.Helper() + log := newRecordingLogger() + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := CreateTestServices(log, nil) + dlrx := &pairDLRXRepo{} + storage := &pairStorage{stubStorage: newStubStorage(), dlrx: dlrx} + server := NewTestServer(log, storage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) + server.config = &Config{MessageEncoding: EncodingJSON} + server.RegisterHandlers() + + conn := &bsscitest.TestConn{Encoding: EncodingJSON} + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "pair-test", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + HandshakeComplete: true, + }, + Conn: conn, + Bidirectional: true, + } + server.RegisterSession(session) + memoryStatus, ok := statusSvc.(*memoryStatusService) + require.True(t, ok, "CreateTestServices returns the in-memory status service") + return server, memoryStatus, dlrx, session, conn +} + +func sendPair(server *Server, session *Session, dlRxStatQry bool) error { + return server.SendDLDataQueue(session.ID, TestEpEui01, [][]byte{{0x01, 0x02}}, 42, + 0, false, nil, 0, false, false, false, false, 1, dlRxStatQry) +} + +// TestSendDLDataQueue_PairEmitsQueryBeforeQueue: with the dlRxStatQry hint the +// BSSCI dlRxStatQry frame precedes the dlDataQue frame, each with its own +// freshly allocated operation ID (query first). +func TestSendDLDataQueue_PairEmitsQueryBeforeQueue(t *testing.T) { + server, statusSvc, dlrx, session, conn := newPairFixture(t) + + require.NoError(t, sendPair(server, session, true)) + + require.Equal(t, 2, conn.MessageCount(), "query and queue frames expected") + qryFrame := conn.GetMessage(0) + queFrame := conn.GetMessage(1) + assert.Equal(t, mioty.CmdDLRxStatusQuery, qryFrame["command"], "query frame precedes the queue frame") + assert.Equal(t, mioty.CmdDLDataQueue, queFrame["command"]) + assert.Equal(t, float64(-1), qryFrame["opId"], "query ID allocated first") + assert.Equal(t, float64(-2), queFrame["opId"], "queue ID allocated second") + assert.Equal(t, 1, dlrx.createCalls, "one correlation row per query") + + // Both recovery records are durably recorded before the frames + _, err := statusSvc.GetPendingOperation(session, -1) + assert.NoError(t, err, "query pending operation recorded") + _, err = statusSvc.GetPendingOperation(session, -2) + assert.NoError(t, err, "queue pending operation recorded") +} + +// TestSendDLDataQueue_NoHintEmitsQueueOnly: without the hint only the +// dlDataQue frame is written and no correlation row is created. +func TestSendDLDataQueue_NoHintEmitsQueueOnly(t *testing.T) { + server, _, dlrx, session, conn := newPairFixture(t) + + require.NoError(t, sendPair(server, session, false)) + + require.Equal(t, 1, conn.MessageCount()) + assert.Equal(t, mioty.CmdDLDataQueue, conn.GetMessage(0)["command"]) + assert.Zero(t, dlrx.createCalls, "no correlation row without the hint") +} + +// TestSendDLDataQueue_BatchPersistFailureEmitsNeitherFrame: a failure to +// durably record the pair's recovery records aborts before any wire write. +func TestSendDLDataQueue_BatchPersistFailureEmitsNeitherFrame(t *testing.T) { + server, statusSvc, _, session, conn := newPairFixture(t) + statusSvc.recordErr = errors.New("insert failed") + + err := sendPair(server, session, true) + + require.Error(t, err, "batch persistence failure must abort the pair") + assert.Zero(t, conn.MessageCount(), "neither frame may reach the wire") +} + +// TestSendDLDataQueue_CorrelationFailureEmitsNeitherFrame: a failure to +// persist the DL RX correlation row is a pre-write failure for the whole pair. +func TestSendDLDataQueue_CorrelationFailureEmitsNeitherFrame(t *testing.T) { + server, statusSvc, dlrx, session, conn := newPairFixture(t) + dlrx.createErr = errors.New("correlation insert failed") + + err := sendPair(server, session, true) + + require.Error(t, err, "correlation persistence failure must abort the pair") + assert.Zero(t, conn.MessageCount(), "neither frame may reach the wire") + _, getErr := statusSvc.GetPendingOperation(session, -1) + assert.Error(t, getErr, "no recovery record is written when the pair aborts") +} + +// TestSendDLDataQueue_QueryWriteFailurePreservesBothOperations: an ambiguous +// write on the query frame aborts the pair, preserves both recovery records +// for resume reissue with their original IDs, and never writes the queue +// frame to the corrupt connection. +func TestSendDLDataQueue_QueryWriteFailurePreservesBothOperations(t *testing.T) { + server, statusSvc, _, session, conn := newPairFixture(t) + conn.FailWrites = true + + err := sendPair(server, session, true) + + require.Error(t, err) + require.ErrorIs(t, err, ErrAmbiguousWrite) + assert.Zero(t, conn.MessageCount(), "no decodable frame was completed") + + _, getErr := statusSvc.GetPendingOperation(session, -1) + assert.NoError(t, getErr, "query operation preserved for resume") + _, getErr = statusSvc.GetPendingOperation(session, -2) + assert.NoError(t, getErr, "queue operation preserved for resume") +} + +// TestSendDLDataQueue_CounterNeverRolledBack: whatever fails, allocated IDs +// stay consumed (harmless gap) - never restored. +func TestSendDLDataQueue_CounterNeverRolledBack(t *testing.T) { + server, statusSvc, _, session, _ := newPairFixture(t) + statusSvc.recordErr = errors.New("insert failed") + + require.Error(t, sendPair(server, session, true)) + assert.Equal(t, int64(-2), session.LastScOpId, "both consumed IDs stay consumed") + + statusSvc.recordErr = nil + require.NoError(t, sendPair(server, session, true)) + assert.Equal(t, int64(-4), session.LastScOpId, "fresh IDs continue past the gap") +} diff --git a/KC-Core/pkg/bssci/downlink_handlers.go b/KC-Core/pkg/bssci/downlink_handlers.go index 2308935..f053460 100644 --- a/KC-Core/pkg/bssci/downlink_handlers.go +++ b/KC-Core/pkg/bssci/downlink_handlers.go @@ -74,6 +74,22 @@ func (s *Server) handleDLDataQueueResponse(_ *Server, session *Session, msg *Mes "bsEui", session.BaseStationEUI, "error", err) } + + // Repeat the idempotent reserved→queued confirmation: the base station + // acknowledged the queue, so if the dispatcher's confirmation was lost + // (send succeeded but the status write failed or the process crashed), + // this repairs the row. An already-queued row is a no-op. + if tenantID, parseErr := strconv.ParseInt(tenantIDStr, 10, 64); parseErr == nil { + //nolint:gosec // G115: queueID > 0 guard ensures safe int64->uint64 conversion + if err := s.storage.MIOTYDownlinks().MarkReservedAsQueued(ctx, uint64(queueID), tenantID, + session.BaseStationEUI, time.Now().UnixNano(), nil, nil); err != nil { + s.logger.WarnContext(ctx, LogBSSCIFailedToConfirmDownlinkQueued, + //nolint:gosec // G115: queueID > 0 guard ensures safe int64->uint64 conversion + "queId", uint64(queueID), + "bsEui", session.BaseStationEUI, + "error", err) + } + } } // Record success event via audit logger @@ -272,10 +288,16 @@ func (s *Server) handleDLDataResultComplete(_ *Server, session *Session, msg *Me // ============================================================================ // SendDLDataQueue sends a downlink data queue operation to queue downlink data for an endpoint -// Supports both single payload and counter-dependent payloads per BSSCI §3.12 +// Supports both single payload and counter-dependent payloads per BSSCI §3.12. +// When dlRxStatQry is true (the SCACI §3.10.1 hint), a BSSCI dlRxStatQry +// operation (rev1 §5.16 / classic §3.16) is paired with the queue: both +// operations are durably persisted together before either frame is written, +// and the query frame precedes the queue frame so the DL RX status query is +// scheduled for the queued downlink's transmission. func (s *Server) SendDLDataQueue(sessionID string, epEui uint64, payloads [][]byte, queId int64, prio float32, cntDepend bool, packetCnt []int64, format uint8, - responseExp bool, responsePrio bool, dlWindReq bool, expOnly bool, tenantID int64) error { + responseExp bool, responsePrio bool, dlWindReq bool, expOnly bool, tenantID int64, + dlRxStatQry bool) error { s.mu.RLock() session, exists := s.sessions[sessionID] @@ -308,11 +330,18 @@ func (s *Server) SendDLDataQueue(sessionID string, epEui uint64, payloads [][]by return err } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the IDs, persist + // the counter once for the whole pair, persist the pending records, then + // write the frames. IDs are never rolled back. The query ID is allocated + // first so its pending row precedes the queue row in reissue order. + var qryOpId int64 + if dlRxStatQry { + qryOpId = session.NextScOpID() + } + opId := session.NextScOpID() + if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { + return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistSessionCounters), err) + } // Build userData based on counter-dependency mode var userDataField interface{} @@ -412,45 +441,82 @@ func (s *Server) SendDLDataQueue(sessionID string, epEui uint64, payloads [][]by "tenantID": strconv.FormatInt(tenantID, 10), // Store as string to avoid JSON float64 issues } - // Persist operation via StatusService (handles both DB + map with SessionOpKey) - // BSSCI §5.11-5.12.3: StatusService is single writer, no manual map write needed - if err := s.persistPendingOperation(session, opId, mioty.CmdDLDataQueue, msg, euiBytes, metadata); err != nil { + // Persist the recovery records before any frame is written. For the + // dlRxStatQry pair, the correlation row and both pending operations are + // durably recorded together (all-or-nothing) so resume can continue the + // pair with its original IDs; a persistence failure emits neither frame. + var qryMsg map[string]interface{} + if dlRxStatQry { + qryMsg = map[string]interface{}{ + "command": mioty.CmdDLRxStatusQuery, + "opId": qryOpId, + "epEui": epEui, + } + if err := s.persistDLRXQueryCorrelation(session, qryOpId, epEui, euiBytes); err != nil { + return err + } + pair := []*PendingOperation{ + s.buildPendingOperation(session, qryOpId, mioty.CmdDLRxStatusQuery, qryMsg, euiBytes, nil), + s.buildPendingOperation(session, opId, mioty.CmdDLDataQueue, msg, euiBytes, metadata), + } + if err := s.persistPendingOperationBatch(session, pair); err != nil { + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLDataQueOperation, + "sessionID", sessionID, + "opId", opId, + "qryOpId", qryOpId, + "error", err) + return err + } + } else if err := s.persistPendingOperation(session, opId, mioty.CmdDLDataQueue, msg, euiBytes, metadata); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLDataQueOperation, "sessionID", sessionID, "opId", opId, "error", err) + return err + } + + // The query frame precedes the queue frame (BSSCI rev1 §5.16: the query is + // scheduled for the next downlink transmission). A query failure aborts + // the pair - the queue frame is never written to a possibly corrupt + // connection. + if dlRxStatQry { + if err := s.sendMessage(session, qryMsg); err != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // Both recovery rows are preserved; resume reissues the pair + // with its original IDs. + s.closeTransportAfterWriteFailure(session, qryOpId, err) + } else { + // Nothing reached the wire - remove both recovery rows. + for _, cleanupOpId := range []int64{qryOpId, opId} { + if cleanupErr := s.removePendingOperation(session, cleanupOpId); cleanupErr != nil { + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToClearPersistedPendingOperation, + "sessionID", session.DbSessionID, + "opId", cleanupOpId, + "error", cleanupErr) + } + } + } + return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendDlRxStatQry), err) + } } - // Send the message with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, msg); err != nil { - // CRITICAL: Rollback operation ID on send failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up pending operation on send failure - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both cache and DB removal - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending rows for + // resume reissue with the original IDs and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire for the queue frame; its recovery row is + // removed (an already-sent query keeps its row). s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToClearPersistedPendingOperation, "sessionID", session.DbSessionID, "opId", opId, "error", cleanupErr) } - return err } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - // Register queue-to-tenant mapping for fast result processing (BSSCI §5.14) - // Extract tenantID from metadata map (stored at line 415) // Production deployments MUST provide a tenantResolver; tests inject test doubles if tenantIDStr, ok := metadata["tenantID"].(string); ok { s.tenantResolver.RegisterQueueTenant(queId, tenantIDStr) @@ -478,11 +544,13 @@ func (s *Server) SendDLDataRevoke(sessionID string, epEui uint64, queId uint64) return fmt.Errorf("%s", ResolveErrorMessage(errCannotSendDlDataRev)) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } // Create dlDataRev using canonical MIOTY type per spec Section 5.13.1 dlDataRev := &mioty.DLDataRevoke{ @@ -519,13 +587,14 @@ func (s *Server) SendDLDataRevoke(sessionID string, epEui uint64, queId uint64) "tenantID": tenantIDStr, // Store per-session tenant for response handler } - // Persist operation via StatusService (handles both DB + map with SessionOpKey) - // BSSCI §5.12: StatusService is single writer, no manual map write needed + // The recovery record must be durable before the frame is written; a + // persistence failure aborts the send, leaving only a consumed-ID gap. if err := s.persistPendingOperation(session, opId, mioty.CmdDLDataRevoke, msg, euiBytes, metadata); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLDataRevOperation, "sessionID", sessionID, "opId", opId, "error", err) + return err } // Record event @@ -559,35 +628,22 @@ func (s *Server) SendDLDataRevoke(sessionID string, epEui uint64, queId uint64) } } - // Send the message with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, msg); err != nil { - // CRITICAL: Rollback operation ID on send failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up pending operation on send failure - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both cache and DB removal - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationAfterSendFailure, "sessionID", session.DbSessionID, "opId", opId, "error", cleanupErr) } - // Note: No manual fallback - trust StatusService single-writer pattern return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendDlDataRev), err) } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - s.logger.InfoContext(s.sessionContext(session), LogBSSCISentDLDataRevToBaseStation, "sessionID", sessionID, "epEui", epEui, @@ -655,11 +711,14 @@ func (s *Server) handleDLDataRevokeResponse(_ *Server, session *Session, msg *Me } } - // Fail fast if queue ID is invalid (BSSCI §5.13 requires valid queId) + // Fail fast if queue ID is invalid (BSSCI §5.13 requires valid queId). + // The error replaces this SC-initiated operation's normal completion, so + // the base station's errorAck finalizes the pending dlDataRev row + // (BSSCI rev1 §5.17 / classic §3.17). if queueID == 0 { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIInvalidQueueIDInRevokeResponse, "opId", msg.OpId) - if sendErr := s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errInvalidQueueID)); sendErr != nil { + if sendErr := s.sendErrorReplacingOperation(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errInvalidQueueID)); sendErr != nil { s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToSendErrorFrame, "error", sendErr) return sendErr } @@ -676,7 +735,7 @@ func (s *Server) handleDLDataRevokeResponse(_ *Server, session *Session, msg *Me var catalogErr *CatalogError if errors.As(err, &catalogErr) { // Service returned a catalog error with token and POSIX code - if sendErr := s.sendError(session, msg.OpId, catalogErr.Posix, ResolveErrorMessage(catalogErr.Token)); sendErr != nil { + if sendErr := s.sendErrorReplacingOperation(session, msg.OpId, catalogErr.Posix, ResolveErrorMessage(catalogErr.Token)); sendErr != nil { s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToSendErrorFrame, "error", sendErr) return sendErr } @@ -687,7 +746,7 @@ func (s *Server) handleDLDataRevokeResponse(_ *Server, session *Session, msg *Me s.logger.ErrorContext(s.sessionContext(session), "Unexpected non-catalog error from ProcessRevokeResponse", "error", err, "opId", msg.OpId) - if sendErr := s.sendError(session, msg.OpId, POSIX_EIO, ResolveErrorMessage(errDatabaseUpdateFailed)); sendErr != nil { + if sendErr := s.sendErrorReplacingOperation(session, msg.OpId, POSIX_EIO, ResolveErrorMessage(errDatabaseUpdateFailed)); sendErr != nil { s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToSendErrorFrame, "error", sendErr) return sendErr } @@ -851,11 +910,14 @@ func (s *Server) reconstitueDLDataQueMessage(sanitizedMsg map[string]interface{} } msg["userData"] = userDataField - // Restore optional MIOTY fields - if format, ok := metadata["format"].(float64); ok && format > 0 { - msg["format"] = uint8(format) - } else if format, ok := metadata["format"].(uint8); ok && format > 0 { - msg["format"] = format + // Restore optional MIOTY fields (canonical numeric coercion covers + // float64, native integers, and the strict json.Number resume decode) + if format, err := coerceInt64(metadata["format"]); err == nil && format > 0 { + formatU8, errToken := safeUint8(format, "format") + if errToken != "" { + return nil, fmt.Errorf("%s: format=%d", ResolveErrorMessage(errToken), format) + } + msg["format"] = formatU8 } if responseExp, ok := metadata["responseExp"].(bool); ok && responseExp { msg["responseExp"] = true @@ -878,35 +940,31 @@ func (s *Server) reconstitueDLDataRevMessage(msg, metadata map[string]interface{ // Enforce command field msg["command"] = mioty.CmdDLDataRevoke - // Restore epEui from metadata (mandatory field per BSSCI §3.13.1) - if epEui, ok := metadata["epEui"].(float64); ok { - msg["epEui"] = uint64(epEui) - } else if epEui, ok := metadata["epEui"].(uint64); ok { - msg["epEui"] = epEui - } else { - // This should not happen if metadata was stored correctly + // Restore epEui from metadata (mandatory field per BSSCI §3.13.1). + // Canonical numeric coercion preserves the full uint64 EUI range under the + // strict json.Number resume decode. + if _, present := metadata["epEui"]; !present { return nil, fmt.Errorf("%s", ResolveErrorMessage(errMissingEpEuiInMetadata)) } + epEui, err := coerceUint64(metadata["epEui"]) + if err != nil { + return nil, fmt.Errorf("%s: %w", ResolveErrorMessage(errMissingEpEuiInMetadata), err) + } + msg["epEui"] = epEui // Restore queId as uint64 per canonical MIOTY type - if queId, ok := metadata["queId"].(float64); ok { - msg["queId"] = uint64(queId) - } else if queId, ok := metadata["queId"].(uint64); ok { - msg["queId"] = queId - } else if queId, ok := metadata["queId"].(int64); ok { - // Handle int64 values stored before uint64 normalization with range guard - if queId >= 0 { - msg["queId"] = uint64(queId) - } else { - return nil, fmt.Errorf("%s: queId=%d", ResolveErrorMessage(errNegativeQueueIDInMetadata), queId) - } - } else { + if _, present := metadata["queId"]; !present { return nil, fmt.Errorf("%s", ResolveErrorMessage(errMissingQueIDInMetadata)) } + queId, err := coerceUint64(metadata["queId"]) + if err != nil { + return nil, fmt.Errorf("%s: %w", ResolveErrorMessage(errNegativeQueueIDInMetadata), err) + } + msg["queId"] = queId // Fix opId type if needed - if opId, ok := msg["opId"].(float64); ok { - msg["opId"] = int64(opId) + if opId, ok := parseOpID(msg["opId"]); ok { + msg["opId"] = opId } return msg, nil @@ -1203,11 +1261,13 @@ func (s *Server) SendDLRXStatusQuery(sessionID string, epEui uint64) error { return fmt.Errorf("%s: %s", ResolveErrorMessage(errSessionNotFound), sessionID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the correlation and recovery records, then write + // the frame. The counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } // Create dlRxStatQry message per MIOTY spec Section 5.16.1 msg := map[string]interface{}{ @@ -1220,75 +1280,34 @@ func (s *Server) SendDLRXStatusQuery(sessionID string, epEui uint64) error { euiBytes := make([]byte, 8) binary.BigEndian.PutUint64(euiBytes, epEui) - // Persist operation via StatusService (handles both DB + map with SessionOpKey) - // BSSCI §5.16.1: StatusService is single writer, no manual map write needed + // The correlation row and the recovery record must both be durable before + // the frame is written; either persistence failure aborts the send. + if err := s.persistDLRXQueryCorrelation(session, opId, epEui, euiBytes); err != nil { + return err + } if err := s.persistPendingOperation(session, opId, mioty.CmdDLRxStatusQuery, msg, euiBytes, nil); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLRxStatQryOperation, "sessionID", sessionID, "opId", opId, "error", err) + return err } - // Track query for correlation (BSSCI §5.15 audit trail) - if s.storage != nil { - dlRxStatusRepo := s.storage.DLRXStatus() - if dlRxStatusRepo != nil { - tenantID := resolvedTenant(session, s.tenantID) - - // Resolve endpoint owner's organization UUID (BSSCI §5.15) - epOwnerOrgUUID, err := s.resolveOwnerOrgUUID(s.sessionContext(session), tenantID, euiBytes) - if err != nil { - s.logger.WarnContext(s.sessionContext(session), "Failed to resolve endpoint owner org for DL RX query tracking", - "error", err, - "tenant", tenantID, - "epEui", epEui) - // Continue with nil org - backward compatible, but logged - } - - // Convert base station EUI to bytes (euiBytes already created for endpoint at line 1309) - bsEuiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(bsEuiBytes, session.BaseStationEUI) - - if err := dlRxStatusRepo.CreateDLRXStatusQuery(s.sessionContext(session), tenantID, epOwnerOrgUUID, euiBytes, bsEuiBytes, opId); err != nil { - s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToPersistDLRxStatQueryTracking, - "sessionID", session.DbSessionID, - "opId", opId, - "epEui", epEui, - "error", err) - // Non-fatal: Continue sending query even if tracking persistence fails - } - } - // Repository unavailable is non-fatal - continue sending query - } - - // Send the message with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, msg); err != nil { - // CRITICAL: Rollback operation ID on send failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up pending operation from database on send failure - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both DB and memory cleanup - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationAfterSendFailure, "sessionID", session.DbSessionID, "opId", opId, "error", cleanupErr) } - return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendDlRxStatQry), err) } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - s.logger.InfoContext(s.sessionContext(session), LogBSSCISentDLRxStatQryToBaseStation, "sessionID", sessionID, "epEui", epEui, @@ -1297,6 +1316,48 @@ func (s *Server) SendDLRXStatusQuery(sessionID string, epEui uint64) error { return nil } +// persistDLRXQueryCorrelation durably records the dl_rx_status_queries +// correlation row for an outgoing dlRxStatQry (BSSCI rev1 §5.16 / classic +// §3.16) under the endpoint owner's tenant. Correlation persistence failure is +// a pre-write failure: the caller must not put the query on the wire, because +// the eventual dlRxStat report could not be attributed. Owner-organization +// resolution failure alone stays non-fatal (nil org, backward compatible). +func (s *Server) persistDLRXQueryCorrelation(session *Session, opId int64, epEui uint64, euiBytes []byte) error { + if s.storage == nil || s.storage.DLRXStatus() == nil { + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLRxStatQueryTracking, + "sessionID", session.DbSessionID, + "opId", opId, + "epEui", epEui) + return NewCatalogError(errFailedToPersistDLRxCorrelation, POSIX_EPROTO) + } + + tenantID := resolvedTenant(session, s.tenantID) + + // Resolve endpoint owner's organization UUID (BSSCI §5.15) + epOwnerOrgUUID, err := s.resolveOwnerOrgUUID(s.sessionContext(session), tenantID, euiBytes) + if err != nil { + s.logger.WarnContext(s.sessionContext(session), LogBSSCIFailedToResolveOwnerOrgForDLRxQuery, + "error", err, + "tenant", tenantID, + "epEui", epEui) + // Continue with nil org - backward compatible, but logged + } + + bsEuiBytes := make([]byte, 8) + binary.BigEndian.PutUint64(bsEuiBytes, session.BaseStationEUI) + + if err := s.storage.DLRXStatus().CreateDLRXStatusQuery(s.sessionContext(session), tenantID, epOwnerOrgUUID, euiBytes, bsEuiBytes, opId); err != nil { + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLRxStatQueryTracking, + "sessionID", session.DbSessionID, + "opId", opId, + "epEui", epEui, + "error", err) + return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistDLRxCorrelation), err) + } + + return nil +} + // handleDLRXStatusQueryResponse handles dlRxStatQryRsp from base station func (s *Server) handleDLRXStatusQueryResponse(_ *Server, session *Session, msg *Message, _ map[string]interface{}) error { if session == nil { @@ -1407,20 +1468,21 @@ func (s *Server) persistDLRXStatus(ctx context.Context, tenantID int64, ownerOrg return nil } -// QueueDownlink queues a downlink message for delivery via SCACI interface +// QueueDownlink dispatches a SCACI-queued downlink message (SCACI §3.10). // -// This method satisfies the DownlinkScheduler interface defined in KC-Core/pkg/scaci/server.go -// and is called by SCACI when an Application Center queues a downlink message (SCACI §3.10). -// -// Parameters: -// - req: DL data queue request from SCACI containing endpoint, payload, and delivery options -// - tenantID: Tenant context for base station selection and queue persistence +// This method satisfies the DownlinkScheduler interface defined in +// KC-Core/pkg/scheduler and is called by SCACI after the queue row has been +// persisted with status 'pending'. Delivery goes through the downlink +// dispatcher's DispatchQueue so both the immediate (SCACI) and deferred +// (dlOpen auto-dispatch) paths share one pending→reserved→queued lifecycle +// and both honor the dlRxStatQry pairing (SCACI §3.10.1, BSSCI rev1 §5.16 / +// classic §3.16). // // Returns: // - queuedQueId: Actual queue ID assigned (same as requested if no collision) // - bsEui: EUI of base station that will deliver the message // - error: scheduler.ErrSchedulerNoResources if temporary, scheduler.ErrSchedulerResourceMissing for permanent failures -func (s *Server) QueueDownlink(req *mioty.DLDataQueue, tenantID int64) (queuedQueId uint64, bsEui uint64, err error) { +func (s *Server) QueueDownlink(ctx context.Context, req *mioty.DLDataQueue, tenantID int64) (queuedQueId uint64, bsEui uint64, err error) { // Select appropriate bidirectional base station session sessionID, selectedBsEui, err := s.SelectBidirectionalSession(tenantID, nil) if err != nil { @@ -1439,65 +1501,34 @@ func (s *Server) QueueDownlink(req *mioty.DLDataQueue, tenantID int64) (queuedQu return 0, 0, err } - // Convert request fields to SendDLDataQueue parameters - payloads := req.UserData // Range guard for uint64 -> int64 conversion if req.QueId > math.MaxInt64 { return 0, 0, fmt.Errorf("%s: queId=%d", ResolveErrorMessage(errQueueIDOutOfRange), req.QueId) } - queId := int64(req.QueId) - - // Handle optional fields with defaults per SCACI §3.10.1 - prio := float32(0) - if req.Prio != nil { - prio = *req.Prio - } - format := uint8(0) - if req.Format != nil { - format = *req.Format - } - - responseExp := false - if req.ResponseExp != nil { - responseExp = *req.ResponseExp - } - - responsePrio := false - if req.ResponsePrio != nil { - responsePrio = *req.ResponsePrio - } - - dlWindReq := false - if req.DlWindReq != nil { - dlWindReq = *req.DlWindReq - } - - expOnly := false - if req.ExpOnly != nil { - expOnly = *req.ExpOnly + s.mu.RLock() + session, exists := s.sessions[sessionID] + s.mu.RUnlock() + if !exists { + return 0, 0, scheduler.ErrSchedulerResourceMissing } - // Convert PacketCnt from []uint32 to []int64 for BSSCI compatibility - var packetCnt []int64 - if req.CntDepend && len(req.PacketCnt) > 0 { - packetCnt = make([]int64, len(req.PacketCnt)) - for i, cnt := range req.PacketCnt { - packetCnt[i] = int64(cnt) - } + if s.downlinkDispatcher == nil { + return 0, 0, scheduler.ErrSchedulerNoResources } - // Call existing SendDLDataQueue to handle the BSSCI protocol details. - // The SCACI-only dlRxStatQry hint is not part of mioty.DLDataQueue and is - // honoured by the auto-dispatcher (which reads DlRxStatQry from the - // storage.DownlinkMessage row and pairs the §3.16 query with the queue - // operation), not here. - err = s.SendDLDataQueue(sessionID, req.EpEui, payloads, queId, - prio, req.CntDepend, packetCnt, format, - responseExp, responsePrio, dlWindReq, expOnly, tenantID) + // Dispatch the exact persisted queue row; the dispatcher reserves it, + // sends (with dlRxStatQry pairing when the row requests it), and confirms + // reserved→queued. + dispatched, err := s.downlinkDispatcher.DispatchQueue(ctx, tenantID, session.OrganizationID, session, req.QueId, req.EpEui) if err != nil { return 0, 0, err } + if !dispatched { + // No matching pending row: it was never persisted, already dispatched, + // or revoked in the meantime. + return 0, 0, scheduler.ErrSchedulerQueueNotFound + } // Return the queue ID and selected base station EUI return req.QueId, selectedBsEui, nil diff --git a/KC-Core/pkg/bssci/downlink_handlers_test.go b/KC-Core/pkg/bssci/downlink_handlers_test.go index d8a1fb4..c17ade5 100644 --- a/KC-Core/pkg/bssci/downlink_handlers_test.go +++ b/KC-Core/pkg/bssci/downlink_handlers_test.go @@ -73,6 +73,7 @@ func TestSendDLDataRevoke_BuildsMessage_PersistsMetadata(t *testing.T) { Encoding: EncodingMessagePack, LastScOpId: -1, HandshakeComplete: true, + DbSessionID: 1, }, Conn: mockConn, Bidirectional: true, diff --git a/KC-Core/pkg/bssci/error_ack_disposition_test.go b/KC-Core/pkg/bssci/error_ack_disposition_test.go new file mode 100644 index 0000000..3204219 --- /dev/null +++ b/KC-Core/pkg/bssci/error_ack_disposition_test.go @@ -0,0 +1,144 @@ +package bssci + +import ( + "testing" + "time" + + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newErrorAckFixture builds a server + session with one live pending SC +// operation recorded at opID, for exercising the errorAck disposition rules +// (BSSCI rev1 §5.17 / classic §3.17). +func newErrorAckFixture(t *testing.T, opID int64) (*Server, StatusService, *Session) { + t.Helper() + log := newRecordingLogger() + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, storage := CreateTestServices(log, nil) + server := NewTestServer(log, storage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) + server.config = &Config{MessageEncoding: EncodingJSON} + server.RegisterHandlers() + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "errorack-test", + BaseStationEUI: TestBsEui01, + ResolvedTenantID: 1, + DbSessionID: 1, + Encoding: EncodingJSON, + HandshakeComplete: true, + ConnectState: ConnectStateComplete, + }, + Conn: &bsscitest.TestConn{Encoding: EncodingJSON}, + } + pendingOp := &PendingOperation{ + OperationType: mioty.CmdDLDataQueue, + CreatedAt: time.Now(), + Metadata: map[string]interface{}{}, + } + require.NoError(t, statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, session.DbSessionID)) + return server, statusSvc, session +} + +func errorAckMsg(opID int64) (*Message, map[string]interface{}) { + data := map[string]interface{}{"command": mioty.CmdErrorAck, "opId": opID} + return &Message{Command: mioty.CmdErrorAck, OpId: opID, Data: data}, data +} + +// TestErrorAck_Unsolicited_RemovesNothing: an errorAck for an operation this +// service center never sent an error about must not touch the live pending +// operation - a spurious or forged errorAck cannot finalize in-flight work. +func TestErrorAck_Unsolicited_RemovesNothing(t *testing.T) { + const opID = int64(-7) + server, statusSvc, session := newErrorAckFixture(t, opID) + + msg, data := errorAckMsg(opID) + require.NoError(t, server.handleErrorAck(server, session, msg, data)) + + _, err := statusSvc.GetPendingOperation(session, opID) + assert.NoError(t, err, "unsolicited errorAck must not remove the pending operation") +} + +// TestErrorAck_AckOnly_RemovesNothing: a plain rejection error (sendError) +// solicits an errorAck that closes the exchange without touching pending +// state, even when a pending operation happens to share the opId. +func TestErrorAck_AckOnly_RemovesNothing(t *testing.T) { + const opID = int64(-7) + server, statusSvc, session := newErrorAckFixture(t, opID) + + require.NoError(t, server.sendError(session, opID, POSIX_EPROTO, "test rejection")) + + msg, data := errorAckMsg(opID) + require.NoError(t, server.handleErrorAck(server, session, msg, data)) + + _, err := statusSvc.GetPendingOperation(session, opID) + assert.NoError(t, err, "ack-only errorAck must not remove the pending operation") +} + +// TestErrorAck_Finalizing_RemovesExactlyOnce: an error that replaced a pending +// SC operation's normal sequence is completed by the errorAck, which finalizes +// that operation; a duplicate errorAck is ignored. +func TestErrorAck_Finalizing_RemovesExactlyOnce(t *testing.T) { + const opID = int64(-7) + server, statusSvc, session := newErrorAckFixture(t, opID) + + require.NoError(t, server.sendErrorReplacingOperation(session, opID, POSIX_EPROTO, "operation replaced")) + + msg, data := errorAckMsg(opID) + require.NoError(t, server.handleErrorAck(server, session, msg, data)) + + _, err := statusSvc.GetPendingOperation(session, opID) + assert.Error(t, err, "finalizing errorAck completes the operation and removes its pending row") + + // A duplicate errorAck finds no awaited entry and changes nothing + require.NoError(t, server.handleErrorAck(server, session, msg, data)) +} + +// TestErrorAck_WrongSession_RemovesNothing: the awaited-errorAck expectation +// is connection-scoped; an errorAck arriving on a different session for the +// same opId must not finalize the other session's operation. +func TestErrorAck_WrongSession_RemovesNothing(t *testing.T) { + const opID = int64(-7) + server, statusSvc, session := newErrorAckFixture(t, opID) + + require.NoError(t, server.sendErrorReplacingOperation(session, opID, POSIX_EPROTO, "operation replaced")) + + otherSession := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "other-session", + BaseStationEUI: TestBsEui02, + ResolvedTenantID: 1, + DbSessionID: 2, + Encoding: EncodingJSON, + HandshakeComplete: true, + ConnectState: ConnectStateComplete, + }, + Conn: &bsscitest.TestConn{Encoding: EncodingJSON}, + } + + msg, data := errorAckMsg(opID) + require.NoError(t, server.handleErrorAck(server, otherSession, msg, data)) + + _, err := statusSvc.GetPendingOperation(session, opID) + assert.NoError(t, err, "an errorAck on a different session must not finalize this session's operation") +} + +// TestErrorAck_PositiveOpID_NoPendingTouch: an errorAck acknowledging an error +// this service center sent about a BS-initiated (positive) operation consumes +// the expectation without any pending-operation removal. +func TestErrorAck_PositiveOpID_NoPendingTouch(t *testing.T) { + const negOpID = int64(-7) + const posOpID = int64(9) + server, statusSvc, session := newErrorAckFixture(t, negOpID) + + require.NoError(t, server.sendError(session, posOpID, POSIX_EPROTO, "inbound command rejected")) + + msg, data := errorAckMsg(posOpID) + require.NoError(t, server.handleErrorAck(server, session, msg, data)) + + _, err := statusSvc.GetPendingOperation(session, negOpID) + assert.NoError(t, err, "positive-opId errorAck must not touch SC pending operations") +} diff --git a/KC-Core/pkg/bssci/errors_catalog.go b/KC-Core/pkg/bssci/errors_catalog.go index d1870b6..f486ab4 100644 --- a/KC-Core/pkg/bssci/errors_catalog.go +++ b/KC-Core/pkg/bssci/errors_catalog.go @@ -204,9 +204,15 @@ const ( // Status operation errors errFailedToSendStatusRequest = "bssci.error.failed_to_send_status_request" errFailedToPersistPendingStatusOperation = "bssci.error.failed_to_persist_pending_status_operation" - errMissingStatusCode = "bssci.error.missing_status_code" - errMissingStatusMessage = "bssci.error.missing_status_message" - errMissingStatusTime = "bssci.error.missing_status_time" + + // SC-initiated operation durability errors (BSSCI rev1 §5.2 / classic §3.2) + errPendingOpSessionNotPersisted = "bssci.error.pending_op_session_not_persisted" + errFailedToPersistPendingOperation = "bssci.error.failed_to_persist_pending_operation" + errFailedToPersistSessionCounters = "bssci.error.failed_to_persist_session_counters" + errFailedToPersistDLRxCorrelation = "bssci.error.failed_to_persist_dlrx_correlation" + errMissingStatusCode = "bssci.error.missing_status_code" + errMissingStatusMessage = "bssci.error.missing_status_message" + errMissingStatusTime = "bssci.error.missing_status_time" // Management HTTP API errors (BSSCI §3.6 attach/§3.7 detach propagation) // Exported for use by pkg/management HTTP handlers @@ -1279,6 +1285,30 @@ var errorDefinitions = map[string]ErrorDefinition{ SpecSection: "§3.5", Severity: SeverityError, }, + errPendingOpSessionNotPersisted: { + Token: "bssci.error.pending_op_session_not_persisted", + Message: "SC operation requires a persisted session before its recovery record can be written", + SpecSection: "§3.2", + Severity: SeverityError, + }, + errFailedToPersistPendingOperation: { + Token: "bssci.error.failed_to_persist_pending_operation", + Message: "Failed to persist pending operation recovery record", + SpecSection: "§3.2", + Severity: SeverityError, + }, + errFailedToPersistSessionCounters: { + Token: "bssci.error.failed_to_persist_session_counters", + Message: "Failed to persist session operation counters", + SpecSection: "§3.2", + Severity: SeverityError, + }, + errFailedToPersistDLRxCorrelation: { + Token: "bssci.error.failed_to_persist_dlrx_correlation", + Message: "Failed to persist DL RX status query correlation record", + SpecSection: "§3.16", + Severity: SeverityError, + }, errMissingStatusCode: { Token: "bssci.error.missing_status_code", Message: "Missing mandatory field: code", diff --git a/KC-Core/pkg/bssci/log_messages.go b/KC-Core/pkg/bssci/log_messages.go index 3c1ba67..e5eefb5 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -115,6 +115,12 @@ const ( LogBSSCIVersionIncompatible = "Version incompatible" // LogBSSCIReceivedErrorAckFromBaseStation is a log message constant LogBSSCIReceivedErrorAckFromBaseStation = "Received errorAck from base station" + // LogBSSCIFailedToResolveOwnerOrgForDLRxQuery is a log message constant + LogBSSCIFailedToResolveOwnerOrgForDLRxQuery = "Failed to resolve endpoint owner org for DL RX query tracking" + // LogBSSCIUnsolicitedErrorAck is a log message constant + LogBSSCIUnsolicitedErrorAck = "Ignoring errorAck with no matching sent error" + // LogBSSCIClosingConnectionAfterWriteFailure is a log message constant + LogBSSCIClosingConnectionAfterWriteFailure = "Closing connection after ambiguous frame write; pending operations preserved for resume" // LogBSSCISendingBSSCIError is a log message constant LogBSSCISendingBSSCIError = "Sending BSSCI error" // LogBSSCIPersistedPendingOperation is a log message constant @@ -634,6 +640,10 @@ const ( LogDispatcherSendFailed = "Downlink dispatcher: send failed" // LogDispatcherMarkSentFailed is logged when marking downlink as queued fails LogDispatcherMarkSentFailed = "Downlink dispatcher: mark queued failed" + // LogDispatcherReleaseFailed is logged when releasing a reservation back to pending fails + LogDispatcherReleaseFailed = "Downlink dispatcher: failed to release reservation to pending" + // LogBSSCIFailedToConfirmDownlinkQueued is a log message constant + LogBSSCIFailedToConfirmDownlinkQueued = "Failed to confirm downlink queue row as queued after dlDataQueRsp" // LogDispatcherTxCommitFailed is logged when transaction commit fails LogDispatcherTxCommitFailed = "Downlink dispatcher: commit failed" // LogDispatcherSuccess is logged when downlink successfully dispatched diff --git a/KC-Core/pkg/bssci/mandatory_field_validation_test.go b/KC-Core/pkg/bssci/mandatory_field_validation_test.go index 5df63a4..8a50ce4 100644 --- a/KC-Core/pkg/bssci/mandatory_field_validation_test.go +++ b/KC-Core/pkg/bssci/mandatory_field_validation_test.go @@ -498,8 +498,12 @@ func TestConnectMandatoryFields(t *testing.T) { } } -// TestRollbackOnSendFailure verifies LastScOpId restored on send failure per BSSCI §3.2 -func TestRollbackOnSendFailure(t *testing.T) { +// TestSendFailureConsumesOpIDAndPreservesOperation verifies the durable-order +// contract (BSSCI rev1 §5.2 / classic §3.2): a wire write failure never rolls +// the SC operation counter back (a rollback would race concurrent allocations +// and reissue a live ID), and the persisted pending operation survives for +// resume reissue with its original ID. +func TestSendFailureConsumesOpIDAndPreservesOperation(t *testing.T) { testLogger := logger.NewNop() mockConn := &validationMockConn{} mockConn.Reset() @@ -518,6 +522,7 @@ func TestRollbackOnSendFailure(t *testing.T) { BaseStationEUI: TestBsEui01, Encoding: "json", HandshakeComplete: true, + DbSessionID: 1, LastScOpId: -5, }, Conn: mockConn, @@ -530,8 +535,16 @@ func TestRollbackOnSendFailure(t *testing.T) { _, err := server.SendStatusRequest(session) require.Error(t, err, "SendStatusRequest should fail when write fails") - assert.Equal(t, initialOpId, session.LastScOpId, - "LastScOpId should be restored on send failure") + require.ErrorIs(t, err, ErrAmbiguousWrite, + "a wire write failure is an ambiguous write") + assert.Equal(t, initialOpId-1, session.LastScOpId, + "the consumed operation ID is never rolled back") + + // The recovery record survives the ambiguous write so resume reissues the + // operation with its original ID + pendingOp, getErr := statusSvc.GetPendingOperation(session, initialOpId-1) + require.NoError(t, getErr, "pending operation must be preserved for resume") + assert.Equal(t, mioty.CmdStatus, pendingOp.OperationType) } // TestAttachMandatoryFields verifies BSSCI §3.6.1 mandatory field validation for attach handler diff --git a/KC-Core/pkg/bssci/message_assembly_test.go b/KC-Core/pkg/bssci/message_assembly_test.go index ddd5d20..833dccc 100644 --- a/KC-Core/pkg/bssci/message_assembly_test.go +++ b/KC-Core/pkg/bssci/message_assembly_test.go @@ -14,11 +14,29 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vmihailenco/msgpack/v5" ) +// assemblyDLRXRepo is a no-op DLRXStatusRepository: only the correlation write +// used by SendDLRXStatusQuery is implemented. +type assemblyDLRXRepo struct { + interfaces.DLRXStatusRepository +} + +func (assemblyDLRXRepo) CreateDLRXStatusQuery(_ context.Context, _ int64, _ *uuid.UUID, _, _ []byte, _ int64) error { + return nil +} + +// assemblyStorage provides the minimal storage surface for SC-originated send +// tests: a working DL RX correlation repository and nil-safe accessors. +type assemblyStorage struct{ capturingStorage } + +func (s *assemblyStorage) MIOTYMessages() interfaces.MIOTYMessageRepository { return nil } +func (s *assemblyStorage) DLRXStatus() interfaces.DLRXStatusRepository { return assemblyDLRXRepo{} } + // TestSCOriginatedMessageAssembly verifies BSSCI §2.5-01: // Every mandatory field must be populated when assembling Service Center-originated // protocol messages. Tests use real send helpers and capture actual bytes in both @@ -103,10 +121,10 @@ func TestSCOriginatedOperations(t *testing.T) { // Create test server and session sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, - queueSerializer, auditLogger, tenantResolver, mockStorage := + queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(testLogger, nil) - server := bssci.NewTestServer(testLogger, mockStorage, nil, 1, + server := bssci.NewTestServer(testLogger, &assemblyStorage{}, nil, 1, sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) @@ -117,6 +135,7 @@ func TestSCOriginatedOperations(t *testing.T) { Encoding: encoding, LastScOpId: -1, HandshakeComplete: true, + DbSessionID: 1, }, Conn: mockConn, } @@ -248,10 +267,10 @@ func TestBSOriginatedResponseHandlers(t *testing.T) { // Create test server sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, - queueSerializer, auditLogger, tenantResolver, mockStorage := + queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(testLogger, nil) - server := bssci.NewTestServer(testLogger, mockStorage, nil, 1, + server := bssci.NewTestServer(testLogger, &assemblyStorage{}, nil, 1, sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) @@ -261,6 +280,7 @@ func TestBSOriginatedResponseHandlers(t *testing.T) { BaseStationEUI: bssci.TestBsEui04, Encoding: encoding, HandshakeComplete: true, + DbSessionID: 1, }, Conn: mockConn, } @@ -336,10 +356,10 @@ func newAssemblySession(t *testing.T, encoding, sessionID string) (*bssci.Server mockConn.Reset() sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, - queueSerializer, auditLogger, tenantResolver, mockStorage := + queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(logger.NewNop(), nil) - server := bssci.NewTestServer(logger.NewNop(), mockStorage, &mockEventStore{}, 1, + server := bssci.NewTestServer(logger.NewNop(), &assemblyStorage{}, &mockEventStore{}, 1, sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) @@ -350,6 +370,7 @@ func newAssemblySession(t *testing.T, encoding, sessionID string) (*bssci.Server Encoding: encoding, LastScOpId: -1, HandshakeComplete: true, + DbSessionID: 1, }, Conn: mockConn, Bidirectional: true, @@ -476,7 +497,7 @@ func testSendDLDataQueue(t *testing.T, encoding string) { packetCnt := []int64{} err := server.SendDLDataQueue(session.ID, epEui, payloads, queId, - 0, false, packetCnt, 0, false, false, false, false, 1) + 0, false, packetCnt, 0, false, false, false, false, 1, false) require.NoError(t, err, "SendDLDataQueue should succeed") require.Len(t, mockConn.sentMessages, 1, "Should send exactly one message") @@ -562,7 +583,7 @@ func testSendDLDataQueueCounterDependent(t *testing.T, encoding string) { packetCnt := []int64{100, 200, 300} // Counter-dependent packet counts err := server.SendDLDataQueue(session.ID, epEui, payloads, queId, - 0, true, packetCnt, 0, false, false, false, false, 1) + 0, true, packetCnt, 0, false, false, false, false, 1, false) require.NoError(t, err, "SendDLDataQueue with cntDepend should succeed") require.Len(t, mockConn.sentMessages, 1, "Should send exactly one message") @@ -607,7 +628,7 @@ func testSendDLDataQueueACKOnly(t *testing.T, encoding string) { err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, [][]byte{}, 1001, 0, false, nil, 0, - false, false, false, false, 1) + false, false, false, false, 1, false) require.NoError(t, err, "ACK-only SendDLDataQueue should succeed") require.Len(t, mockConn.sentMessages, 1) msg := mockConn.sentMessages[0] @@ -637,7 +658,7 @@ func testSendDLDataQueueOptionalFlagsTrue(t *testing.T, encoding string) { err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, [][]byte{{0x02}}, 1002, 0.5, false, nil, 0, - true, true, true, true, 1) + true, true, true, true, 1, false) require.NoError(t, err) require.Len(t, mockConn.sentMessages, 1) msg := mockConn.sentMessages[0] @@ -657,7 +678,7 @@ func testSendDLDataQueueOptionalFlagsFalse(t *testing.T, encoding string) { err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, [][]byte{{0x02}}, 1003, 0.5, false, nil, 0, - false, false, false, false, 1) + false, false, false, false, 1, false) require.NoError(t, err) require.Len(t, mockConn.sentMessages, 1) msg := mockConn.sentMessages[0] @@ -686,7 +707,7 @@ func testSendDLDataQueueFormatBoundary(t *testing.T, encoding string) { err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, [][]byte{{0x02}}, 1004, 0.5, false, nil, tc.format, - false, false, false, false, 1) + false, false, false, false, 1, false) require.NoError(t, err) require.Len(t, mockConn.sentMessages, 1) msg := mockConn.sentMessages[0] @@ -724,7 +745,7 @@ func testSendDLDataQueuePrioAlwaysPresent(t *testing.T, encoding string) { err := server.SendDLDataQueue(session.ID, bssci.TestEpEui01, [][]byte{{0x02}}, 1005, prio, false, nil, 0, - false, false, false, false, 1) + false, false, false, false, 1, false) require.NoError(t, err) require.Len(t, mockConn.sentMessages, 1) msg := mockConn.sentMessages[0] diff --git a/KC-Core/pkg/bssci/pending_op_leak_test.go b/KC-Core/pkg/bssci/pending_op_leak_test.go index 7497a14..09b79fe 100644 --- a/KC-Core/pkg/bssci/pending_op_leak_test.go +++ b/KC-Core/pkg/bssci/pending_op_leak_test.go @@ -41,10 +41,13 @@ func TestSCInitiatedOperationsFinalizeAfterCmp(t *testing.T) { } cases := []struct { - name string - opType string - respCmd string - invoke func(s *Server, sess *Session, msg *Message, data map[string]interface{}) error + name string + opType string + respCmd string + endpoint []byte + metadata map[string]interface{} + setup func(s *Server) + invoke func(s *Server, sess *Session, msg *Message, data map[string]interface{}) error }{ { name: "status", @@ -54,6 +57,29 @@ func TestSCInitiatedOperationsFinalizeAfterCmp(t *testing.T) { return s.handleStatusResponse(s, sess, msg, data) }, }, + { + name: "dlDataQue", + opType: mioty.CmdDLDataQueue, + respCmd: mioty.CmdDLDataQueueResponse, + endpoint: []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77}, + metadata: map[string]interface{}{"queId": int64(42), "tenantID": "1"}, + invoke: func(s *Server, sess *Session, msg *Message, data map[string]interface{}) error { + return s.handleDLDataQueueResponse(s, sess, msg, data) + }, + }, + { + name: "dlDataRev", + opType: mioty.CmdDLDataRevoke, + respCmd: mioty.CmdDLDataRevokeResponse, + endpoint: []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77}, + metadata: map[string]interface{}{"queId": int64(42), "tenantID": "1"}, + setup: func(s *Server) { + s.downlinkSvc = &mqttTestDownlinkService{} + }, + invoke: func(s *Server, sess *Session, msg *Message, data map[string]interface{}) error { + return s.handleDLDataRevokeResponse(s, sess, msg, data) + }, + }, { name: "dlRxStatQry", opType: mioty.CmdDLRxStatusQuery, @@ -67,12 +93,20 @@ func TestSCInitiatedOperationsFinalizeAfterCmp(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { server, statusSvc, session := newServer(t) + if tc.setup != nil { + tc.setup(server) + } const opID = int64(-7) + metadata := tc.metadata + if metadata == nil { + metadata = map[string]interface{}{} + } pendingOp := &PendingOperation{ OperationType: tc.opType, + Endpoint: tc.endpoint, CreatedAt: time.Now(), - Metadata: map[string]interface{}{}, + Metadata: metadata, } require.NoError(t, statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, session.DbSessionID)) _, err := statusSvc.GetPendingOperation(session, opID) @@ -81,7 +115,7 @@ func TestSCInitiatedOperationsFinalizeAfterCmp(t *testing.T) { data := map[string]interface{}{ "command": tc.respCmd, "opId": opID, - // status response mandatory fields (ignored by dlRxStatQry) + // status response mandatory fields (ignored by the others) "code": int64(0), "message": "ok", "time": time.Now().UnixNano(), diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index 9fa05d4..7edc33a 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -280,6 +280,32 @@ type ProtocolSessionState struct { mu sync.Mutex } +// NextScOpID allocates the next Service Center operation ID for this session +// (negative, strictly decrementing per BSSCI rev1 §5.2 / classic §3.2). The +// consumed ID is never rolled back on a later failure: a rollback would race +// concurrent allocations and reissue an ID already held by an in-flight +// operation, so a failed operation simply leaves a harmless gap. +func (p *ProtocolSessionState) NextScOpID() int64 { + p.mu.Lock() + defer p.mu.Unlock() + p.LastScOpId-- + return p.LastScOpId +} + +// errorAckDisposition classifies what the errorAck answering a sent error +// frame is allowed to do (BSSCI rev1 §5.17 / classic §3.17). +type errorAckDisposition int + +const ( + // errorAckAckOnly: the errorAck merely closes the error exchange; it must + // not touch any pending operation. + errorAckAckOnly errorAckDisposition = iota + // errorAckFinalizePendingOperation: the sent error replaced the normal + // response/completion of a known pending SC operation, so the errorAck + // completes that operation and its pending row is finalized. + errorAckFinalizePendingOperation +) + // Session represents a connected Base Station session: the transport-free // ProtocolSessionState plus the live transport resources (socket, certificate, // background channels) that never cross an application-service boundary. @@ -302,6 +328,44 @@ type Session struct { // pendingBaseStation caches the registration looked up during the connect // request so connect-complete does not repeat the lookup pendingBaseStation *basestation.BaseStation + // pendingErrorAcks tracks the nonzero operation IDs for which this service + // center has sent an error frame and awaits the base station's errorAck + // (BSSCI rev1 §5.17 / classic §3.17). The exchange is connection-scoped + // and never survives resume; connect handshake errors (opId 0) are tracked + // by ConnectState instead. Guarded by the ProtocolSessionState mutex. + pendingErrorAcks map[int64]errorAckDisposition + // resumePendingOps is the strictly decoded pending-operation snapshot + // loaded during a compatible resume, held on the provisional connection + // until conCmp activation restores the cache and reissues the eligible + // operations (BSSCI rev1 §5.3.1 / classic §3.3.1). + resumePendingOps []*PendingOperation +} + +// registerPendingErrorAck records that an error frame was sent for opId and a +// matching errorAck is now expected. opId 0 (connect) is never registered. +func (s *Session) registerPendingErrorAck(opId int64, disposition errorAckDisposition) { + if opId == 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.pendingErrorAcks == nil { + s.pendingErrorAcks = make(map[int64]errorAckDisposition) + } + s.pendingErrorAcks[opId] = disposition +} + +// consumePendingErrorAck removes and returns the awaited-errorAck entry for +// opId. ok is false when no error frame was sent for that operation on this +// connection, in which case the errorAck is unsolicited. +func (s *Session) consumePendingErrorAck(opId int64) (errorAckDisposition, bool) { + s.mu.Lock() + defer s.mu.Unlock() + disposition, ok := s.pendingErrorAcks[opId] + if ok { + delete(s.pendingErrorAcks, opId) + } + return disposition, ok } // BaseStationEUIBytes converts the BaseStationEUI uint64 to a byte slice. @@ -1331,8 +1395,11 @@ func (s *Server) handleConnection(conn net.Conn) { RawPayload: payload, // Capture original wire bytes for forensic analysis } - // BSSCI-3.2: Validate operation ID (except for connect which is special) - if command != mioty.CmdConnect { + // BSSCI-3.2: Validate operation ID. Connect is special (always opId + // 0), and error/errorAck are exempt: they carry the opId of the + // operation whose sequence they replace (rev1 §5.17 / classic §3.17), + // in either direction, so they never start a new sequence position. + if command != mioty.CmdConnect && command != mioty.CmdError && command != mioty.CmdErrorAck { // Determine if this is a base station initiated operation // Base station operations: positive IDs (and opId=0 for connect handshake per BSSCI §5.3) // Service center operations: negative IDs @@ -1343,6 +1410,17 @@ func (s *Server) handleConnection(conn net.Conn) { "command", command, "opId", opId, "error_token", errToken) + // During the connect handshake the rejection enters the + // unified error/errorAck sequence (rev1 §5.17 / classic + // §3.17): the acknowledgement completes the failed exchange + // and closes. An active session with a broken operation-ID + // sequence closes immediately so resume restores counter sync. + if !session.HandshakeComplete { + if err := s.rejectConnect(session, opId, POSIX_EPROTO, errToken); err != nil { + return + } + continue + } if err := s.sendError(session, opId, POSIX_EPROTO, ResolveErrorMessage(errToken)); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToSendErrorResponse, "error", err) } @@ -1558,21 +1636,52 @@ func (s *Server) sendMessage(session *Session, msg interface{}) error { //nolint:gosec // G115: int->uint32 safe, guarded by range check at line 664 binary.LittleEndian.PutUint32(header[8:], uint32(len(payload))) - // Send header and payload + // Send header and payload. Any failure past this point is an ambiguous + // write: part of the frame may already be on the wire, so the connection's + // framing can no longer be trusted and callers must close it and rely on + // session resume for recovery (BSSCI rev1 §5.3.1 / classic §3.3.1). session.mu.Lock() defer session.mu.Unlock() - if _, err := session.Conn.Write(header); err != nil { - return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToWriteHeader), err) + n, err := session.Conn.Write(header) + if err != nil { + return fmt.Errorf("%s: %w: %w", ResolveErrorMessage(errFailedToWriteHeader), ErrAmbiguousWrite, err) + } + if n < len(header) { + return fmt.Errorf("%s: %w: short write %d/%d", ResolveErrorMessage(errFailedToWriteHeader), ErrAmbiguousWrite, n, len(header)) } - if _, err := session.Conn.Write(payload); err != nil { - return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToWritePayload), err) + n, err = session.Conn.Write(payload) + if err != nil { + return fmt.Errorf("%s: %w: %w", ResolveErrorMessage(errFailedToWritePayload), ErrAmbiguousWrite, err) + } + if n < len(payload) { + return fmt.Errorf("%s: %w: short write %d/%d", ResolveErrorMessage(errFailedToWritePayload), ErrAmbiguousWrite, n, len(payload)) } return nil } +// ErrAmbiguousWrite reports a frame write that failed after bytes may have +// reached the wire. The transport framing can no longer be trusted; callers +// close the connection and rely on session resume (with the original +// operation IDs) for recovery instead of retrying on the same connection. +var ErrAmbiguousWrite = errors.New("ambiguous frame write") + +// closeTransportAfterWriteFailure closes the session transport after an +// ambiguous frame write. Persisted pending operations are deliberately +// preserved: the disconnect path marks the session resumable and resume +// reissues the operations with their original IDs. +func (s *Server) closeTransportAfterWriteFailure(session *Session, opId int64, cause error) { + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIClosingConnectionAfterWriteFailure, + "bsEui", session.BaseStationEUI, + "opId", opId, + "error", cause) + if session.Conn != nil { + _ = session.Conn.Close() + } +} + // operationAckTimeout returns the configured handshake wait bound, falling // back to the package default when unset. func (s *Server) operationAckTimeout() time.Duration { @@ -1896,10 +2005,32 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m session.OrganizationID = previousSession.OrganizationID // Restore org from previous session session.ResolvedTenantID = previousSession.ResolvedTenantID // Restore tenant from previous session + // Load and strictly decode the persisted pending operations BEFORE + // conRsp: an infrastructure or decode failure rejects the resume + // instead of silently losing protocol state. The validated snapshot + // stays on the provisional connection until conCmp activates it. + pendingOps, loadErr := s.loadPendingOperations(session) + if loadErr != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToLoadPendingOperationsForSessionResume, + "error", loadErr, + "sessionID", session.DbSessionID) + return s.rejectConnect(session, msg.OpId, POSIX_EAGAIN, errSessionResumeUnavailable) + } + session.resumePendingOps = pendingOps + + // Counter floor: the SC counter must be at or below every persisted + // negative operation ID, so reissued IDs are never re-allocated + for _, op := range pendingOps { + if op.OperationID < 0 && op.OperationID < session.LastScOpId { + session.LastScOpId = op.OperationID + } + } + session.ScOpId = session.LastScOpId + s.logger.InfoContext(s.safeCtx(), LogBSSCIResumingPreviousSession, "eui", session.BaseStationEUI, "bsOpId", session.BsOpId, - "scOpId", session.ScOpId, + "scOpId", session.LastScOpId, "dbSessionId", session.DbSessionID, "orgID", session.OrganizationID.String(), "tenantID", session.ResolvedTenantID) @@ -2102,89 +2233,86 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message } } - // Start status mechanism for this session - s.startStatusMechanism(session) - s.logger.InfoContext(ctx, LogBSSCIStartedStatusMechanismForBaseStation, - "bsEui", session.BaseStationEUI) - - // MIOTY session resume: Load and reissue pending operations - if session.DbSessionID > 0 { - pendingOps, err := s.loadPendingOperations(session) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToLoadPendingOperationsForSessionResume, - "error", err, - "sessionID", session.DbSessionID) - } else if len(pendingOps) > 0 { - s.logger.InfoContext(ctx, LogBSSCIResumingSessionWithPendingOps, - "bsEui", session.BaseStationEUI, - "sessionID", session.DbSessionID, - "pendingCount", len(pendingOps)) + // MIOTY session resume: restore the validated pending-operation snapshot + // loaded before conRsp (never re-read or re-written here - the DB rows are + // already authoritative), reissue the eligible operations in deterministic + // order with their original IDs, and only then start status polling so the + // first status request cannot interleave with the reissue sequence. + if pendingOps := session.resumePendingOps; len(pendingOps) > 0 { + session.resumePendingOps = nil + s.logger.InfoContext(ctx, LogBSSCIResumingSessionWithPendingOps, + "bsEui", session.BaseStationEUI, + "sessionID", session.DbSessionID, + "pendingCount", len(pendingOps)) - // Reissue all pending operations per MIOTY BSSCI v1.0.0 session resume requirements - for _, op := range pendingOps { - s.logger.DebugContext(ctx, LogBSSCIProcessingPendingOperationForResume, - "opId", op.OperationID, - "type", op.OperationType) + for _, op := range pendingOps { + s.logger.DebugContext(ctx, LogBSSCIProcessingPendingOperationForResume, + "opId", op.OperationID, + "type", op.OperationType) - // Reconstitute operations BEFORE storing or sending - if op.OperationType == mioty.CmdULDataTransmit && op.Metadata != nil { - reconstituted, err := s.reconstitueULDataTxMessage(op.Message, op.Metadata, op) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteULDataTxSkipping, - "error", err, - "opId", op.OperationID) - continue - } - op.Message = reconstituted - } else if op.OperationType == mioty.CmdDLDataQueue && op.Metadata != nil { - // Reconstitute dlDataQue with counter-dependent payloads - reconstituted, err := s.reconstitueDLDataQueMessage(op.Message, op.Metadata, op) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataQueSkipping, - "error", err, - "opId", op.OperationID) - continue - } - op.Message = reconstituted - } else if op.OperationType == mioty.CmdDLDataRevoke && op.Metadata != nil { - // Reconstitute mioty.CmdDLDataRevoke with proper integer types - reconstituted, err := s.reconstitueDLDataRevMessage(op.Message, op.Metadata) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataRevSkipping, - "error", err, - "opId", op.OperationID) - continue - } - op.Message = reconstituted + // Reconstitute operations BEFORE storing or sending + if op.OperationType == mioty.CmdULDataTransmit && op.Metadata != nil { + reconstituted, err := s.reconstitueULDataTxMessage(op.Message, op.Metadata, op) + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteULDataTxSkipping, + "error", err, + "opId", op.OperationID) + continue } - - // Delegate pending operation storage to StatusService - // BSSCI §§5.11-5.12.3 Gap 1: Guard against nil statusSvc (test compatibility) - if s.statusSvc != nil { - if err := s.statusSvc.RecordPendingOperation(ctx, session, op.OperationID, op, session.DbSessionID); err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToRecordPendingOperation, - "error", err, - "opId", op.OperationID) - // Continue - operation can still be reissued - } + op.Message = reconstituted + } else if op.OperationType == mioty.CmdDLDataQueue && op.Metadata != nil { + // Reconstitute dlDataQue with counter-dependent payloads + reconstituted, err := s.reconstitueDLDataQueMessage(op.Message, op.Metadata, op) + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataQueSkipping, + "error", err, + "opId", op.OperationID) + continue } - - // Normalize message types to fix JSON float64 conversion before reissuing - normalizedMsg := s.normalizeMessageTypes(op.Message, op.OperationType) - if err := s.sendMessage(session, normalizedMsg); err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToReissuePendingOperation, + op.Message = reconstituted + } else if op.OperationType == mioty.CmdDLDataRevoke && op.Metadata != nil { + // Reconstitute mioty.CmdDLDataRevoke with proper integer types + reconstituted, err := s.reconstitueDLDataRevMessage(op.Message, op.Metadata) + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataRevSkipping, "error", err, - "opId", op.OperationID, - "type", op.OperationType) + "opId", op.OperationID) + continue } + op.Message = reconstituted + } + + // Cache-only hydration: the loaded DB rows are authoritative and + // must not be UPSERTed back. BS-initiated (positive) operations + // are hydrated for response correlation but never transmitted. + if s.statusSvc != nil { + s.statusSvc.RestorePendingOperation(session, op.OperationID, op) + } + + if !isResumableScOperation(op.OperationID, op.OperationType) { + continue + } + + // Normalize message types to fix JSON float64 conversion before reissuing + normalizedMsg := s.normalizeMessageTypes(op.Message, op.OperationType) + if err := s.sendMessage(session, normalizedMsg); err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToReissuePendingOperation, + "error", err, + "opId", op.OperationID, + "type", op.OperationType) } - } else { - s.logger.DebugContext(ctx, LogBSSCINoPendingOperationsToResume, - "bsEui", session.BaseStationEUI, - "sessionID", session.DbSessionID) } + } else if session.DbSessionID > 0 { + s.logger.DebugContext(ctx, LogBSSCINoPendingOperationsToResume, + "bsEui", session.BaseStationEUI, + "sessionID", session.DbSessionID) } + // Start status mechanism only after the reissue sequence completed + s.startStatusMechanism(session) + s.logger.InfoContext(ctx, LogBSSCIStartedStatusMechanismForBaseStation, + "bsEui", session.BaseStationEUI) + // BSSCI §5.8.3: Trigger automatic attach propagate reconciliation for newly connected BS // Replay all bidirectional endpoints to the newly connected base station // Run asynchronously to avoid blocking the complete message response @@ -2275,11 +2403,13 @@ func (s *Server) SendPing(sessionID string) error { return fmt.Errorf("%s for session %s", ResolveErrorMessage(errCannotSendPing), sessionID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID and + // persist the counter before the frame is written; never roll back. Ping + // is idempotent liveness traffic, so no pending record is persisted. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } msg := map[string]interface{}{ "command": mioty.CmdPing, @@ -2291,24 +2421,13 @@ func (s *Server) SendPing(sessionID string) error { "bsEui", session.BaseStationEUI, "opId", opId) - // Send with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, msg); err != nil { - // CRITICAL: Rollback on send failure to maintain operation ID consistency - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() + if errors.Is(err, ErrAmbiguousWrite) { + s.closeTransportAfterWriteFailure(session, opId, err) + } return err } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.safeCtx(), session); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - return nil } @@ -2338,11 +2457,13 @@ func (s *Server) InitiatePing(ctx context.Context, baseStationEUI uint64, tenant baseStationEUI, sessionTenantID, tenantID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID and + // persist the counter before the frame is written; never roll back. Ping + // is idempotent liveness traffic, so no pending record is persisted. + opId, err := s.beginScOperation(session) + if err != nil { + return 0, err + } msg := map[string]interface{}{ "command": mioty.CmdPing, @@ -2354,24 +2475,13 @@ func (s *Server) InitiatePing(ctx context.Context, baseStationEUI uint64, tenant "bsEui", session.BaseStationEUI, "opId", opId) - // Send with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, msg); err != nil { - // CRITICAL: Rollback on send failure to maintain operation ID consistency - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() + if errors.Is(err, ErrAmbiguousWrite) { + s.closeTransportAfterWriteFailure(session, opId, err) + } return 0, err } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(ctx, session); err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", session.ID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - return opId, nil } @@ -3229,8 +3339,11 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma typedMetadata.EndpointID = endpoint.ID } - // Persist pending operation via StatusService (handles both DB + map with SessionOpKey) - // BSSCI §5.7: StatusService is single writer for crash safety (guard against zero DbSessionID) + // Persist pending operation via StatusService (handles both DB + map with SessionOpKey). + // This row is a crash-resume aid for a BS-initiated operation (positive + // opId): the abort-before-send rule protects SC-initiated recovery only, + // and aborting here would drop a live detach on a transient DB failure, so + // persistence stays best-effort (BSSCI §5.7). if session.DbSessionID != 0 { if err := s.persistPendingOperation(session, int64(msg.OpId), mioty.CmdDetach, data, euiBytes, metadataMap); err != nil { s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToPersistPendingOperationMigrationNeeded, @@ -4174,9 +4287,21 @@ func (s *Server) handleErrorAck(_ *Server, session *Session, msg *Message, _ map return s.rejectConnect(session, msg.OpId, POSIX_EPROTO, errInvalidHandshakeState) } - // For SC-initiated operations (negative opId), clean up pending operation - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both cache and DB removal - if msg.OpId < 0 { + // An errorAck is only meaningful when this service center actually sent an + // error frame for that operation on this connection (BSSCI rev1 §5.17 / + // classic §3.17). Consuming the recorded expectation prevents a spurious + // or forged errorAck from finalizing an unrelated in-flight operation. + disposition, awaited := session.consumePendingErrorAck(msg.OpId) + if !awaited { + s.logger.WarnContext(s.safeCtx(), LogBSSCIUnsolicitedErrorAck, + "opId", msg.OpId, + "baseStationEUI", session.BaseStationEUI) + return nil + } + + // Only an error that replaced a pending SC operation's normal sequence may + // finalize that operation; ack-only errors touch no pending state. + if disposition == errorAckFinalizePendingOperation && msg.OpId < 0 { if err := s.removePendingOperation(session, msg.OpId); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperationAfterErrorAck, "error", err, @@ -4185,8 +4310,13 @@ func (s *Server) handleErrorAck(_ *Server, session *Session, msg *Message, _ map } } - // For BS-initiated operations (positive opId), just log - no cleanup needed - // The base station is acknowledging our error message + // An errorAck that completes an error sent during the connect handshake + // finishes the failed exchange: the state machine goes Terminal and the + // connection closes (BSSCI rev1 §5.17 / classic §3.17) + if session.ConnectState == ConnectStateAwaitingConnectErrorAck { + session.ConnectState = ConnectStateTerminal + return fmt.Errorf("connect-stage error acknowledged by the base station; closing") + } return nil } @@ -4307,6 +4437,46 @@ func decodeJSONFrame(rawFrame []byte) (map[string]interface{}, error) { return data, nil } +// normalizeStrictDecodedMap converts a strict-decoded (UseNumber) map into the +// value shapes the legacy decoder produced: integral numbers within the exact +// float64 range become float64 (so existing consumers keep working), while +// integers beyond that range stay exact as int64/uint64 - this is what +// preserves full-range EUI-64 and counter values across resume. +func normalizeStrictDecodedMap(m map[string]interface{}) map[string]interface{} { + for k, v := range m { + m[k] = normalizeStrictDecodedValue(v) + } + return m +} + +func normalizeStrictDecodedValue(v interface{}) interface{} { + switch t := v.(type) { + case json.Number: + if i, err := jsonNumberToInt64(t); err == nil { + if i >= -int64(maxExactFloat64Integer) && i <= int64(maxExactFloat64Integer) { + return float64(i) + } + return i + } + if u, err := jsonNumberToUint64(t); err == nil { + return u + } + if f, err := t.Float64(); err == nil { + return f + } + return t + case map[string]interface{}: + return normalizeStrictDecodedMap(t) + case []interface{}: + for i, e := range t { + t[i] = normalizeStrictDecodedValue(e) + } + return t + default: + return v + } +} + func getStringField(data map[string]interface{}, key string, defaultValue string) string { if v, ok := data[key].(string); ok { return v @@ -4475,25 +4645,27 @@ func mapToDetachMetadata(m map[string]interface{}) *detachMetadata { } // Extract endpointID (optional for backward compatibility with old pending operations) + // Canonical numeric coercion accepts both legacy float64-decoded values and + // the strict json.Number decode used on resume (exact uint64/int64 range). var endpointID int64 - if idFloat, ok := m["endpointID"].(float64); ok { - endpointID = int64(idFloat) + if id, err := coerceInt64(m["endpointID"]); err == nil { + endpointID = id } - packetCntFloat, ok := m["packetCnt"].(float64) - if !ok { + packetCntInt, err := coerceInt64(m["packetCnt"]) + if err != nil { return nil } - rxTimeFloat, ok := m["rxTime"].(float64) - if !ok { + rxTime, err := coerceInt64(m["rxTime"]) + if err != nil { return nil } - snr, ok := m["snr"].(float64) - if !ok { + snr, err := coerceFloat64(m["snr"]) + if err != nil { return nil } - rssi, ok := m["rssi"].(float64) - if !ok { + rssi, err := coerceFloat64(m["rssi"]) + if err != nil { return nil } @@ -4517,7 +4689,7 @@ func mapToDetachMetadata(m map[string]interface{}) *detachMetadata { } // Build typed metadata with safe conversions (using conversions.go helpers) - packetCnt, errToken := safeUint32(int64(packetCntFloat), "packetCnt") + packetCnt, errToken := safeUint32(packetCntInt, "packetCnt") if errToken != "" { return nil // Failed conversion } @@ -4527,14 +4699,14 @@ func mapToDetachMetadata(m map[string]interface{}) *detachMetadata { EndpointID: endpointID, PacketCnt: packetCnt, Signature: signature, - RxTime: int64(rxTimeFloat), + RxTime: rxTime, SNR: snr, RSSI: rssi, } // Extract tenantId (optional for backward compatibility with old pending operations) - if tenantIdFloat, ok := m["tenantId"].(float64); ok { - result.TenantID = int64(tenantIdFloat) + if tenantID, err := coerceInt64(m["tenantId"]); err == nil { + result.TenantID = tenantID } // Extract orgUuid (optional for backward compatibility) @@ -4545,14 +4717,13 @@ func mapToDetachMetadata(m map[string]interface{}) *detachMetadata { } // Optional fields - if eqSnrFloat, ok := m["eqSnr"].(float64); ok { - result.EqSnr = &eqSnrFloat + if eqSnr, err := coerceFloat64(m["eqSnr"]); err == nil && m["eqSnr"] != nil { + result.EqSnr = &eqSnr } if profileStr, ok := m["profile"].(string); ok { result.Profile = &profileStr } - if rxDurFloat, ok := m["rxDuration"].(float64); ok { - rxDur := int64(rxDurFloat) + if rxDur, err := coerceInt64(m["rxDuration"]); err == nil && m["rxDuration"] != nil { result.RxDuration = &rxDur } @@ -5041,10 +5212,13 @@ func (s *Server) SendAttachPropagate(sessionID string, endpointEUI uint64, nwkSn } // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } // Convert repetition uint8 to boolean (non-zero means repetition enabled) repetitionBool := repetition > 0 @@ -5102,9 +5276,11 @@ func (s *Server) SendAttachPropagate(sessionID string, endpointEUI uint64, nwkSn metadata["organizationId"] = ownerOrgUUID.String() } + // The recovery record must be durable before the frame is written; a + // persistence failure aborts the send, leaving only a consumed-ID gap. if err := s.persistPendingOperation(session, opId, mioty.CmdAttachPropagate, message, euiBytes, metadata); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToPersistPendingOperation, "error", err) - // Continue anyway - persistence failure shouldn't block operation + return err } // Update endpoint in database with attach propagate information @@ -5229,21 +5405,17 @@ func (s *Server) SendAttachPropagate(sessionID string, endpointEUI uint64, nwkSn // which creates a single "attPrp" event. Removed duplicate "attach_propagate_initiated" events // that were creating 2 extra records per operation. - // Send with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, message); err != nil { - // CRITICAL: Rollback on send failure to maintain operation ID consistency - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up persisted operation (guard for community builds) - if session.DbSessionID > 0 { - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToClearPersistedPendingOperation, - "sessionID", session.DbSessionID, - "opId", opId, - "error", cleanupErr) - } + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. + s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToClearPersistedPendingOperation, + "sessionID", session.DbSessionID, + "opId", opId, + "error", cleanupErr) } return err @@ -5307,17 +5479,6 @@ func (s *Server) SendAttachPropagate(sessionID string, endpointEUI uint64, nwkSn } } - // Success - persist counter to DB for session resume - if s.sessionSvc != nil { - if err := s.sessionSvc.UpdateSessionCounters(s.safeCtx(), session); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", session.ID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - } - return nil } @@ -5853,11 +6014,13 @@ func (s *Server) SendDetachPropagate(sessionID string, endpointEUI uint64) error return fmt.Errorf("%s for session %s", ResolveErrorMessage(errHandshakeNotComplete), sessionID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } s.logger.InfoContext(s.safeCtx(), LogBSSCISendingDetachPropagate, "sessionID", sessionID, @@ -5885,9 +6048,11 @@ func (s *Server) SendDetachPropagate(sessionID string, endpointEUI uint64) error metadata["organizationId"] = ownerOrgUUID.String() } + // The recovery record must be durable before the frame is written; a + // persistence failure aborts the send, leaving only a consumed-ID gap. if err := s.persistPendingOperation(session, opId, mioty.CmdDetachPropagate, message, euiBytes, metadata); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToPersistPendingOperation, "error", err) - // Continue anyway - persistence failure shouldn't block operation + return err } // Update endpoint in database to mark detach propagate initiated @@ -5966,37 +6131,22 @@ func (s *Server) SendDetachPropagate(sessionID string, endpointEUI uint64) error } } - // Send with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, message); err != nil { - // CRITICAL: Rollback on send failure to maintain operation ID consistency - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up persisted operation (guard for community builds) - if session.DbSessionID > 0 { - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToClearPersistedPendingOperation, - "sessionID", session.DbSessionID, - "opId", opId, - "error", cleanupErr) - } + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. + s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToClearPersistedPendingOperation, + "sessionID", session.DbSessionID, + "opId", opId, + "error", cleanupErr) } return err } - // Success - persist counter to DB for session resume - if s.sessionSvc != nil { - if err := s.sessionSvc.UpdateSessionCounters(s.safeCtx(), session); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", session.ID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - } - return nil } @@ -6291,17 +6441,61 @@ func (s *Server) handleDetachPropagateComplete(_ *Server, session *Session, msg // Database persistence methods for MIOTY session resume +// beginScOperation allocates the next SC operation ID and durably persists the +// session counters before any frame is written (BSSCI rev1 §5.2 / classic +// §3.2). The durable order for every SC-issued operation is: allocate the ID, +// persist the counter, persist the pending record (recoverable operations +// only), then write the frame. A failure after allocation leaves a harmless +// consumed-ID gap; the counter is never rolled back because a rollback races +// concurrent allocations. +func (s *Server) beginScOperation(session *Session) (int64, error) { + opId := session.NextScOpID() + if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { + return 0, fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistSessionCounters), err) + } + return opId, nil +} + // persistPendingOperation stores a pending operation via StatusService (BSSCI §5.11-5.12.3 single writer) // StatusService handles both DB persistence and in-memory map update using SessionOpKey composite key func (s *Server) persistPendingOperation(session *Session, opId int64, opType string, message map[string]interface{}, euiBytes []byte, metadata map[string]interface{}) error { ctx := s.safeCtx() + // An SC operation without a persisted session has no recovery identity; + // letting it on the wire would make it unrecoverable after a crash. This + // is an inconsistent-session error, never a silent no-persistence mode. if session.DbSessionID == 0 { - s.logger.WarnContext(ctx, LogBSSCIDatabaseNotAvailableForPendingOpPersistence) - return nil + s.logger.ErrorContext(ctx, LogBSSCIDatabaseNotAvailableForPendingOpPersistence, + "opId", opId, + "opType", opType) + return NewCatalogError(errPendingOpSessionNotPersisted, POSIX_EPROTO) } - // Extract MACType and Data from metadata if present (for VM operations) + pendingOp := s.buildPendingOperation(session, opId, opType, message, euiBytes, metadata) + + // StatusService is the single path for pending operation persistence. A + // failure is surfaced to the caller: an SC operation whose recovery record + // was never durably written must not go on the wire. + if err := s.statusSvc.RecordPendingOperation(ctx, session, opId, pendingOp, session.DbSessionID); err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToPersistPendingOperationMigrationNeeded, + "error", err, + "sessionID", session.DbSessionID, + "opId", opId) + return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistPendingOperation), err) + } + + s.logger.DebugContext(ctx, LogBSSCIPersistedPendingOperation, + "sessionID", session.DbSessionID, + "opId", opId, + "opType", opType) + + return nil +} + +// buildPendingOperation assembles the recovery record for an SC-initiated +// operation, extracting the VM MACType and payload data from metadata when +// present. +func (s *Server) buildPendingOperation(session *Session, opId int64, opType string, message map[string]interface{}, euiBytes []byte, metadata map[string]interface{}) *PendingOperation { var macType int var data []byte if metadata != nil { @@ -6330,8 +6524,7 @@ func (s *Server) persistPendingOperation(session *Session, opId int64, opType st } } - // Build complete PendingOperation struct - pendingOp := &PendingOperation{ + return &PendingOperation{ SessionSlug: session.ID, OperationID: opId, OperationType: opType, @@ -6342,22 +6535,30 @@ func (s *Server) persistPendingOperation(session *Session, opId int64, opType st Metadata: metadata, CreatedAt: time.Now(), } +} - // StatusService is the single path for pending operation persistence - if err := s.statusSvc.RecordPendingOperation(ctx, session, opId, pendingOp, session.DbSessionID); err != nil { - // Log but don't fail - pending operations are best effort - s.logger.WarnContext(ctx, LogBSSCIFailedToPersistPendingOperationMigrationNeeded, +// persistPendingOperationBatch durably records several recovery records in one +// repository transaction (all-or-nothing) so a multi-frame sequence such as +// the dlRxStatQry/dlDataQue pair never has a partially persisted recovery +// state. The same inconsistent-session rule as persistPendingOperation +// applies. +func (s *Server) persistPendingOperationBatch(session *Session, ops []*PendingOperation) error { + ctx := s.safeCtx() + + if session.DbSessionID == 0 { + s.logger.ErrorContext(ctx, LogBSSCIDatabaseNotAvailableForPendingOpPersistence, + "opCount", len(ops)) + return NewCatalogError(errPendingOpSessionNotPersisted, POSIX_EPROTO) + } + + if err := s.statusSvc.RecordPendingOperations(ctx, session, ops, session.DbSessionID); err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToPersistPendingOperationMigrationNeeded, "error", err, "sessionID", session.DbSessionID, - "opId", opId) - return nil + "opCount", len(ops)) + return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistPendingOperation), err) } - s.logger.DebugContext(ctx, LogBSSCIPersistedPendingOperation, - "sessionID", session.DbSessionID, - "opId", opId, - "opType", opType) - return nil } @@ -6410,6 +6611,25 @@ func (s *Server) removePendingOperation(session *Session, opId int64) error { return s.statusSvc.RemovePendingOperation(ctx, session, opId) } +// isResumableScOperation reports whether a persisted pending operation may be +// reissued on session resume: only SC-initiated (negative ID) non-VM commands +// qualify (BSSCI rev1 §5.3.1 / classic §3.3.1). BS-initiated operations are +// hydrated for response correlation but never transmitted by the SC, and VM +// operations require re-established endpoint VM state before reissue. +func isResumableScOperation(opID int64, opType string) bool { + if opID >= 0 { + return false + } + switch opType { + case mioty.CmdStatus, mioty.CmdAttachPropagate, mioty.CmdDetachPropagate, + mioty.CmdULDataTransmit, mioty.CmdDLDataQueue, mioty.CmdDLDataRevoke, + mioty.CmdDLRxStatusQuery: + return true + default: + return false + } +} + // loadPendingOperations loads pending operations from database for session resume (Issue #3: accepts *Session for SessionSlug field) func (s *Server) loadPendingOperations(session *Session) ([]*PendingOperation, error) { sessionID := session.DbSessionID @@ -6436,24 +6656,31 @@ func (s *Server) loadPendingOperations(session *Session) ([]*PendingOperation, e metadataJSON := repoOp.Metadata createdAt := repoOp.CreatedAt - // Deserialize operation data - var operationData map[string]interface{} - if err := json.Unmarshal(operationDataJSON, &operationData); err != nil { + // Deserialize operation data with the strict frame decoder (UseNumber, + // single object, trailing content rejected) so uint64 EUI values + // survive resume exactly. A malformed persisted operation is an + // infrastructure inconsistency: the caller rejects the resume rather + // than silently losing protocol state. + operationData, err := decodeJSONFrame(operationDataJSON) + if err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToUnmarshalOperationData, "error", err, "opId", opId) - continue + return nil, fmt.Errorf("%s: opId %d: %w", ResolveErrorMessage(errFailedToDecode), opId, err) } + operationData = normalizeStrictDecodedMap(operationData) - // Deserialize metadata + // Deserialize metadata under the same strict rules var metadata map[string]interface{} if len(metadataJSON) > 0 { - if err := json.Unmarshal(metadataJSON, &metadata); err != nil { + metadata, err = decodeJSONFrame(metadataJSON) + if err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToUnmarshalMetadata, "error", err, "opId", opId) - metadata = make(map[string]interface{}) + return nil, fmt.Errorf("%s: opId %d metadata: %w", ResolveErrorMessage(errFailedToDecode), opId, err) } + metadata = normalizeStrictDecodedMap(metadata) } else { metadata = make(map[string]interface{}) } @@ -6876,7 +7103,29 @@ func (s *Server) sendError(session *Session, opId int64, code int, message strin "code", code, "message", message) - return s.sendMessage(session, errorMsg) + if err := s.sendMessage(session, errorMsg); err != nil { + return err + } + + // The base station will answer with errorAck (BSSCI rev1 §5.17 / classic + // §3.17). Record the expectation so handleErrorAck only acts on + // acknowledgements this service center actually solicited. Plain + // rejections are ack-only; sendErrorReplacingOperation registers the + // finalizing disposition for errors that replace a pending SC operation. + session.registerPendingErrorAck(opId, errorAckAckOnly) + return nil +} + +// sendErrorReplacingOperation sends an error frame that replaces the normal +// response/completion sequence of a known pending SC operation (BSSCI rev1 +// §5.17 / classic §3.17). The base station's errorAck then completes that +// operation, so the errorAck is registered with the finalizing disposition. +func (s *Server) sendErrorReplacingOperation(session *Session, opId int64, code int, message string) error { + if err := s.sendError(session, opId, code, message); err != nil { + return err + } + session.registerPendingErrorAck(opId, errorAckFinalizePendingOperation) + return nil } // sendCatalogError resolves catalog error token and sends error message diff --git a/KC-Core/pkg/bssci/session_opid_rollback_test.go b/KC-Core/pkg/bssci/session_opid_rollback_test.go index d1a6180..2142835 100644 --- a/KC-Core/pkg/bssci/session_opid_rollback_test.go +++ b/KC-Core/pkg/bssci/session_opid_rollback_test.go @@ -1,43 +1,34 @@ package bssci import ( - "sync/atomic" + "sync" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -// TestAtomicSendRollback verifies that the atomic send-and-persist pattern -// correctly rolls back operation ID counters when message send fails. -// This ensures BSSCI §5.2 correctness by preventing operation ID gaps. -func TestAtomicSendRollback(t *testing.T) { +// TestNextScOpIDMonotonicAllocation verifies the durable-order allocation +// contract (BSSCI rev1 §5.2 / classic §3.2): SC operation IDs are negative, +// strictly decrementing, and a consumed ID is never rolled back regardless of +// what happens to the operation afterwards. A rollback would race concurrent +// allocations and reissue an ID already held by an in-flight operation, so a +// failed operation leaves a harmless gap instead. +func TestNextScOpIDMonotonicAllocation(t *testing.T) { tests := []struct { - name string - initialOpId int64 - sendSucceeds bool - expectedOpId int64 - desc string + name string + initialOpId int64 + expected []int64 }{ { - name: "SendSuccessKeepsDecrementedID", - initialOpId: -5, - sendSucceeds: true, - expectedOpId: -6, - desc: "Successful send should keep decremented operation ID", + name: "FirstAllocationFromZero", + initialOpId: 0, + expected: []int64{-1}, }, { - name: "SendFailureRollsBackID", - initialOpId: -5, - sendSucceeds: false, - expectedOpId: -5, - desc: "Failed send should rollback operation ID to original value", - }, - { - name: "FirstOperationRollback", - initialOpId: 0, - sendSucceeds: false, - expectedOpId: 0, - desc: "First operation failure should rollback from -1 to 0", + name: "SequentialAllocations", + initialOpId: -5, + expected: []int64{-6, -7, -8}, }, } @@ -50,275 +41,59 @@ func TestAtomicSendRollback(t *testing.T) { }, } - // Step 1: Decrement operation ID (atomic send-and-persist pattern) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() - - // Verify operation ID was decremented - assert.Equal(t, tt.initialOpId-1, opId, - "Operation ID should be decremented before send attempt") - - // Step 2: Simulate send attempt - if !tt.sendSucceeds { - // CRITICAL: Rollback operation ID on send failure (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() + for i, want := range tt.expected { + got := session.NextScOpID() + assert.Equal(t, want, got, "allocation %d", i) + assert.Equal(t, want, session.LastScOpId, + "counter reflects the consumed ID; it is never restored") } - - // Step 3: Verify final counter state - assert.Equal(t, tt.expectedOpId, session.LastScOpId, tt.desc) }) } } -// TestMultipleRollbacks verifies that multiple consecutive send failures -// correctly rollback operation IDs without causing counter drift. -func TestMultipleRollbacks(t *testing.T) { - session := &Session{ - ProtocolSessionState: ProtocolSessionState{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, - }, - } - - numAttempts := 5 - allFailed := true - - // Simulate multiple failed send attempts - for i := 0; i < numAttempts; i++ { - // Decrement counter - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() - - // Verify operation ID - assert.Equal(t, int64(-1), opId, - "Each attempt should decrement to -1 before rollback") - - // Simulate send failure and rollback - if allFailed { - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - } - - // Verify counter returned to 0 after rollback - assert.Equal(t, int64(0), session.LastScOpId, - "Counter should return to 0 after rollback on attempt %d", i+1) - } -} - -// TestMixedSuccessFailure verifies that the atomic pattern correctly handles -// a mix of successful and failed operations, maintaining counter integrity. -func TestMixedSuccessFailure(t *testing.T) { - session := &Session{ - ProtocolSessionState: ProtocolSessionState{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, - }, - } - - operations := []struct { - succeeds bool - expectedOpId int64 - desc string - }{ - {succeeds: true, expectedOpId: -1, desc: "First operation succeeds"}, - {succeeds: false, expectedOpId: -1, desc: "Second operation fails, stays at -1"}, - {succeeds: true, expectedOpId: -2, desc: "Third operation succeeds"}, - {succeeds: true, expectedOpId: -3, desc: "Fourth operation succeeds"}, - {succeeds: false, expectedOpId: -3, desc: "Fifth operation fails, stays at -3"}, - {succeeds: true, expectedOpId: -4, desc: "Sixth operation succeeds"}, - } - - for i, op := range operations { - // Atomic send-and-persist pattern - session.mu.Lock() - session.LastScOpId-- - session.mu.Unlock() - - // Simulate send result - if !op.succeeds { - // Rollback on failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - } +// TestNextScOpIDConcurrentAllocationNeverReuses proves the shared-counter +// invariant under concurrency: every allocated ID is unique even when many +// goroutines allocate simultaneously (run with -race). This is the property +// the removed failure-path rollback used to violate: incrementing the counter +// after a concurrent goroutine had taken the next ID reissued a live ID. +func TestNextScOpIDConcurrentAllocationNeverReuses(t *testing.T) { + const ( + goroutines = 16 + perGoroutine = 250 + expectedUnique = goroutines * perGoroutine + ) - // Verify counter state - assert.Equal(t, op.expectedOpId, session.LastScOpId, - "Operation %d: %s", i+1, op.desc) - } -} - -// TestRollbackPreservesPendingOperations verifies that rolling back an -// operation ID doesn't affect tracking of other pending operations. -func TestRollbackPreservesPendingOperations(t *testing.T) { session := &Session{ ProtocolSessionState: ProtocolSessionState{ BaseStationEUI: TestEpEui01, - LastScOpId: 0, }, } - // Track pending operations manually (simulating pendingOps map) - pendingOps := make(map[int64]string) - - // Operation 1: succeeds - session.mu.Lock() - session.LastScOpId-- - opId1 := session.LastScOpId - session.mu.Unlock() - pendingOps[opId1] = "statusReq" - // Send succeeds, keep counter at -1 - - assert.Equal(t, int64(-1), session.LastScOpId, "After first success") - assert.Contains(t, pendingOps, int64(-1), "Pending operation should be tracked") - - // Operation 2: fails - session.mu.Lock() - session.LastScOpId-- - opId2 := session.LastScOpId - session.mu.Unlock() - // Don't add to pending ops - send failed - // Rollback counter - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - assert.Equal(t, int64(-1), session.LastScOpId, "After rollback, counter returns to -1") - assert.Contains(t, pendingOps, int64(-1), "Original pending operation still tracked") - assert.NotContains(t, pendingOps, opId2, "Failed operation not added to pending") - - // Operation 3: succeeds - session.mu.Lock() - session.LastScOpId-- - opId3 := session.LastScOpId - session.mu.Unlock() - pendingOps[opId3] = "pingReq" - - assert.Equal(t, int64(-2), session.LastScOpId, "After second success") - assert.Contains(t, pendingOps, int64(-1), "First pending operation still tracked") - assert.Contains(t, pendingOps, int64(-2), "Second pending operation tracked") -} - -// TestRollbackWithConcurrency verifies that rollback logic is thread-safe -// under concurrent access scenarios. -func TestRollbackWithConcurrency(t *testing.T) { - session := &Session{ - ProtocolSessionState: ProtocolSessionState{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, - }, - } - - numGoroutines := 10 - operationsPerGoroutine := 50 - - var successCount atomic.Int64 - done := make(chan bool, numGoroutines) - - for i := 0; i < numGoroutines; i++ { - go func(goroutineID int) { - localSuccess := int64(0) - for j := 0; j < operationsPerGoroutine; j++ { - // Atomic decrement - session.mu.Lock() - session.LastScOpId-- - session.mu.Unlock() - - // Simulate send (alternate success/failure for predictability) - sendSucceeds := (goroutineID+j)%2 == 0 - - if !sendSucceeds { - // Rollback on failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - } else { - localSuccess++ - } + results := make([][]int64, goroutines) + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + ids := make([]int64, 0, perGoroutine) + for i := 0; i < perGoroutine; i++ { + ids = append(ids, session.NextScOpID()) } - successCount.Add(localSuccess) - done <- true - }(i) - } - - // Wait for all goroutines - for i := 0; i < numGoroutines; i++ { - <-done + results[g] = ids + }(g) } - - // Verify final counter equals negative of successful operations - totalSuccess := successCount.Load() - expectedFinal := -totalSuccess - - // The test verifies that LastScOpId reflects only successful operations - // With alternating success/failure and concurrent access, we verify no counter corruption - assert.LessOrEqual(t, session.LastScOpId, int64(0), - "Final counter should be zero or negative (only successful ops counted)") - - t.Logf("Concurrent test: %d total attempts, %d successful, final counter: %d", - numGoroutines*operationsPerGoroutine, totalSuccess, session.LastScOpId) - - // Verify counter is reasonable (within expected range) - // Due to concurrent access, exact equality is hard to guarantee, but it should be close - assert.GreaterOrEqual(t, session.LastScOpId, expectedFinal-10, - "Counter should be close to number of successful operations") -} - -// TestRollbackIntegrity verifies that no operation ID is ever reused -// across rollback scenarios, maintaining BSSCI §5.2 correctness. -func TestRollbackIntegrity(t *testing.T) { - session := &Session{ - ProtocolSessionState: ProtocolSessionState{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, - }, - } - - usedOpIds := make(map[int64]bool) - - operations := []struct { - succeeds bool - }{ - {succeeds: true}, // -1 (used) - {succeeds: false}, // -2 (rolled back, not used) - {succeeds: true}, // -2 (used) - {succeeds: false}, // -3 (rolled back, not used) - {succeeds: false}, // -3 (rolled back, not used) - {succeeds: true}, // -3 (used) - {succeeds: true}, // -4 (used) - } - - for i, op := range operations { - // Atomic decrement - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() - - if op.succeeds { - // Check for duplicate operation IDs (should never happen) - assert.False(t, usedOpIds[opId], - "Operation %d: Operation ID %d should not be reused", i+1, opId) - usedOpIds[opId] = true - } else { - // Rollback - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() + wg.Wait() + + seen := make(map[int64]struct{}, expectedUnique) + for _, ids := range results { + for _, id := range ids { + require.Negative(t, id, "SC operation IDs are negative") + _, dup := seen[id] + require.False(t, dup, "operation ID %d allocated twice", id) + seen[id] = struct{}{} } } - - // Verify expected operation IDs were used - assert.True(t, usedOpIds[-1], "Operation ID -1 should be used") - assert.True(t, usedOpIds[-2], "Operation ID -2 should be used") - assert.True(t, usedOpIds[-3], "Operation ID -3 should be used") - assert.True(t, usedOpIds[-4], "Operation ID -4 should be used") - assert.Equal(t, 4, len(usedOpIds), "Exactly 4 operation IDs should be used") + require.Len(t, seen, expectedUnique) + assert.Equal(t, int64(-expectedUnique), session.LastScOpId, + "counter equals the most negative allocated ID") } diff --git a/KC-Core/pkg/bssci/status_handlers.go b/KC-Core/pkg/bssci/status_handlers.go index 6caafaa..2e9dfa0 100644 --- a/KC-Core/pkg/bssci/status_handlers.go +++ b/KC-Core/pkg/bssci/status_handlers.go @@ -3,6 +3,7 @@ package bssci import ( "encoding/hex" "encoding/json" + "errors" "fmt" "time" @@ -318,11 +319,16 @@ func (s *Server) SendStatusRequest(session interface{}) (int64, error) { return 0, fmt.Errorf("%s", ResolveErrorMessage(errInvalidSessionType)) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - sess.mu.Lock() - sess.LastScOpId-- - opId := sess.LastScOpId - sess.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back on failure. + opId, err := s.beginScOperation(sess) + if err != nil { + s.logger.ErrorContext(s.sessionContext(sess), LogBSSCIFailedToSendStatusRequest, + "bsEui", sess.BaseStationEUI, + "error", err) + return 0, err + } statusRequest := map[string]interface{}{ "command": mioty.CmdStatus, @@ -335,28 +341,24 @@ func (s *Server) SendStatusRequest(session interface{}) (int64, error) { // The recovery record must be durable before the operation goes on the // wire: an SC operation whose pending row was never persisted cannot be - // reissued on resume. A persistence failure rolls the operation ID back - // and aborts the send. + // reissued on resume. A persistence failure aborts the send, leaving only + // a consumed-ID gap. if err := s.persistPendingOperation(sess, opId, mioty.CmdStatus, statusRequest, nil, nil); err != nil { s.logger.ErrorContext(s.sessionContext(sess), LogBSSCIFailedToPersistPendingStatusOperation, "error", err) - sess.mu.Lock() - sess.LastScOpId++ - sess.mu.Unlock() return 0, fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistPendingStatusOperation), err) } - // Send message with rollback guard (BSSCI §5.2) if err := s.sendMessage(sess, statusRequest); err != nil { - // CRITICAL: Rollback operation ID on send failure - sess.mu.Lock() - sess.LastScOpId++ - sess.mu.Unlock() - s.logger.ErrorContext(s.sessionContext(sess), LogBSSCIFailedToSendStatusRequest, "bsEui", sess.BaseStationEUI, "error", err) - // Clean up pending operation since sending failed - if cleanupErr := s.removePendingOperation(sess, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(sess, opId, err) + } else if cleanupErr := s.removePendingOperation(sess, opId); cleanupErr != nil { + // Nothing reached the wire; the pending row is removed so resume + // does not reissue an operation that was never sent. s.logger.ErrorContext(s.sessionContext(sess), LogBSSCIFailedToCleanupPendingOpAfterSendFailure, "error", cleanupErr, "opId", opId) @@ -364,14 +366,5 @@ func (s *Server) SendStatusRequest(session interface{}) (int64, error) { return 0, fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendStatusRequest), err) } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(sess), sess); err != nil { - s.logger.ErrorContext(s.sessionContext(sess), LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sess.ID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - return opId, nil } diff --git a/KC-Core/pkg/bssci/sublayer_test.go b/KC-Core/pkg/bssci/sublayer_test.go index 0f47a0f..c1d313d 100644 --- a/KC-Core/pkg/bssci/sublayer_test.go +++ b/KC-Core/pkg/bssci/sublayer_test.go @@ -61,6 +61,7 @@ func TestBSSCI_4_01_unsupported_sublayer(t *testing.T) { BaseStationEUI: bssci.TestBsEui01, Encoding: "json", HandshakeComplete: true, + DbSessionID: 1, }, Conn: mockConn, } diff --git a/KC-Core/pkg/bssci/test_helpers_test.go b/KC-Core/pkg/bssci/test_helpers_test.go index 518bd51..d7235c9 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -498,16 +498,42 @@ type memoryStatusService struct { pendingOps *map[SessionOpKey]*PendingOperation mu *sync.RWMutex logger logger.Logger + // recordErr, when set, is returned by Record* calls to exercise the + // persist-failure abort path (no wire frame, counter gap only). + recordErr error } func (m *memoryStatusService) RecordPendingOperation(_ context.Context, session *Session, opId int64, op *PendingOperation, _ int64) error { m.mu.Lock() defer m.mu.Unlock() + if m.recordErr != nil { + return m.recordErr + } key := makeSessionOpKey(session, opId) (*m.pendingOps)[key] = op return nil } +func (m *memoryStatusService) RecordPendingOperations(_ context.Context, session *Session, ops []*PendingOperation, _ int64) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.recordErr != nil { + return m.recordErr + } + for _, op := range ops { + key := makeSessionOpKey(session, op.OperationID) + (*m.pendingOps)[key] = op + } + return nil +} + +func (m *memoryStatusService) RestorePendingOperation(session *Session, opId int64, op *PendingOperation) { + m.mu.Lock() + defer m.mu.Unlock() + key := makeSessionOpKey(session, opId) + (*m.pendingOps)[key] = op +} + func (m *memoryStatusService) GetPendingOperation(session *Session, opId int64) (*PendingOperation, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -545,13 +571,6 @@ func (m *memoryStatusService) ProcessOperationStatusUpdate(_ context.Context, _ return nil } -func (m *memoryStatusService) CleanupPendingOp(session *Session, opId int64) { - m.mu.Lock() - defer m.mu.Unlock() - key := makeSessionOpKey(session, opId) - delete(*m.pendingOps, key) -} - // Canonical test mocks defined in test_mocks_test.go (same package, test-only compilation). // Mocks remain in pkg/bssci to avoid import cycle (mocks implement bssci interfaces). // The _test.go suffix ensures test-only compilation, following Go best practices. diff --git a/KC-Core/pkg/bssci/ul_transmit.go b/KC-Core/pkg/bssci/ul_transmit.go index a62907b..554fcd3 100644 --- a/KC-Core/pkg/bssci/ul_transmit.go +++ b/KC-Core/pkg/bssci/ul_transmit.go @@ -96,17 +96,19 @@ func (s *Server) SendULDataTransmit(sessionID string, epEui uint64, nwkSnKey []b return 0, fmt.Errorf("%s %016X", ResolveErrorMessage(errBaseStationNotBidirectional), session.BaseStationEUI) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() - // Validate key length if len(nwkSnKey) != 16 { return 0, fmt.Errorf("%s, got %d", ResolveErrorMessage(errNwkSnKeyInvalidLength), len(nwkSnKey)) } + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return 0, err + } + // Encrypt key for storage ONLY (not for transmission) var encryptedKeyForStorage string isEncrypted := false // Track whether the key was successfully encrypted @@ -190,14 +192,15 @@ func (s *Server) SendULDataTransmit(sessionID string, epEui uint64, nwkSnKey []b sanitizedMsg["profile"] = profile } - // Persist operation for session resume (with encrypted key in metadata) + // The recovery record must be durable before the frame is written; a + // persistence failure aborts the send, leaving only a consumed-ID gap. if err := s.persistPendingOperation(session, opId, mioty.CmdULDataTransmit, sanitizedMsg, euiBytes, metadata); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistULDataTxOperation, "sessionID", sessionID, "opId", opId, "error", err) - // Continue anyway - operation can still proceed + return 0, err } s.logger.InfoContext(s.sessionContext(session), LogBSSCISendingULDataTransmit, @@ -208,24 +211,22 @@ func (s *Server) SendULDataTransmit(sessionID string, epEui uint64, nwkSnKey []b "shAddr", shAddr, "userDataLen", len(userData)) - // Send message with CLEAR key to base station (rollback guard for BSSCI §5.2) + // Send message with CLEAR key to base station if err := s.sendMessage(session, msg); err != nil { - // CRITICAL: Rollback on send failure to maintain operation ID consistency - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationAfterSendFailure, + "sessionID", session.DbSessionID, + "opId", opId, + "error", cleanupErr) + } return 0, err } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.safeCtx(), session); err != nil { - s.logger.Error(LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - return opId, nil } @@ -397,18 +398,9 @@ func (s *Server) reconstitueULDataTxMessage(sanitizedMsg map[string]interface{}, } msg["userData"] = userDataArray } else { - // Safe type conversion for opId from JSON - var opId int64 - switch v := sanitizedMsg["opId"].(type) { - case float64: - opId = int64(v) - case int64: - opId = v - case int: - opId = int64(v) - default: - opId = 0 // Fallback if type is unexpected - } + // Canonical operation ID coercion covers legacy float64 values and + // the strict json.Number resume decode + opId, _ := parseOpID(sanitizedMsg["opId"]) s.logger.WarnContext(ctx, LogBSSCIUserDataMissingFromBothSources, "opId", opId) diff --git a/KC-Core/pkg/bssci/validation_status_test.go b/KC-Core/pkg/bssci/validation_status_test.go index 17ff1c1..a9840dd 100644 --- a/KC-Core/pkg/bssci/validation_status_test.go +++ b/KC-Core/pkg/bssci/validation_status_test.go @@ -120,7 +120,7 @@ func TestDetachMetadataValidationStatusRoundTrip(t *testing.T) { "endpointID": float64(metadataMap["endpointID"].(int64)), "packetCnt": float64(metadataMap["packetCnt"].(uint32)), "signature": metadataMap["signature"], - "rxTime": float64(metadataMap["rxTime"].(int64)), + "rxTime": metadataMap["rxTime"].(int64), "snr": metadataMap["snr"], "rssi": metadataMap["rssi"], "tenantId": float64(metadataMap["tenantId"].(int64)), @@ -161,12 +161,15 @@ func TestMapToDetachMetadataBackwardCompatibility(t *testing.T) { t.Parallel() // Create metadata map WITHOUT validationStatus (simulates old persisted operation) + // Numeric shapes mirror normalizeStrictDecodedMap output: integers within + // the exact float64 range stay float64 (legacy decoder shape), while + // nanosecond timestamps beyond 2^53 arrive exact as int64. oldMetadataMap := map[string]interface{}{ "epEui": float64(TestEpEui01), // Issue #3-4 Fix A3: Endpoint EUI, not Service Center EUI "endpointID": float64(1001), "packetCnt": float64(42), "signature": []byte{0xAA, 0xBB, 0xCC, 0xDD}, - "rxTime": float64(1234567890000000000), + "rxTime": int64(1234567890000000000), "snr": 12.5, "rssi": -80.0, "tenantId": float64(100), @@ -278,7 +281,7 @@ func TestDetachValidationStatusWithOptionalFields(t *testing.T) { "endpointID": float64(metadataMap["endpointID"].(int64)), "packetCnt": float64(metadataMap["packetCnt"].(uint32)), "signature": metadataMap["signature"], - "rxTime": float64(metadataMap["rxTime"].(int64)), + "rxTime": metadataMap["rxTime"].(int64), "snr": metadataMap["snr"], "rssi": metadataMap["rssi"], "tenantId": float64(metadataMap["tenantId"].(int64)), diff --git a/KC-Core/pkg/bssci/version_interop_test.go b/KC-Core/pkg/bssci/version_interop_test.go index 83c83a4..483e3cf 100644 --- a/KC-Core/pkg/bssci/version_interop_test.go +++ b/KC-Core/pkg/bssci/version_interop_test.go @@ -51,6 +51,13 @@ type interopHarness struct { func startInteropServer(t *testing.T, encoding string) *interopHarness { t.Helper() + return startInteropServerWithConfig(t, encoding, nil) +} + +// startInteropServerWithConfig starts the framed harness with a config +// mutation hook so timeout tests can shrink the handshake deadlines. +func startInteropServerWithConfig(t *testing.T, encoding string, mutate func(*Config)) *interopHarness { + t.Helper() log := logger.NewNop() sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := CreateTestServices(log, nil) @@ -67,6 +74,9 @@ func startInteropServer(t *testing.T, encoding string) *interopHarness { MessageEncoding: encoding, OperationAckTimeout: 2 * time.Second, } + if mutate != nil { + mutate(server.config) + } server.RegisterHandlers() ctx, cancel := context.WithCancel(context.Background()) server.ctx = ctx @@ -387,6 +397,28 @@ func TestBSSCIVersionInterop_StateMachineEdges(t *testing.T) { errFrame := h.readFrame() require.Equal(t, mioty.CmdError, frameCommand(errFrame), "a pre-handshake command must be rejected with an error, entering the errorAck sequence") + + // Completing the error/errorAck exchange ends the failed handshake: + // the state machine goes Terminal and the connection closes + h.writeFrame(map[string]interface{}{"command": mioty.CmdErrorAck, "opId": int64(1)}) + h.expectClosed() + }) + + t.Run("InboundConRspRejectedErrorAckTerminates", func(t *testing.T) { + // The full §5.17 sequence for a connect-stage rejection: error frame, + // then the base station's errorAck completes the failed operation and + // the connection closes + h := startInteropServer(t, EncodingJSON) + h.writeFrame(map[string]interface{}{ + "command": mioty.CmdConnectResponse, + "opId": int64(0), + "version": mioty.MIOTYProtocolVersion, + }) + errFrame := h.readFrame() + require.Equal(t, mioty.CmdError, frameCommand(errFrame)) + + h.writeFrame(map[string]interface{}{"command": mioty.CmdErrorAck, "opId": int64(0)}) + h.expectClosed() }) t.Run("DuplicateCon", func(t *testing.T) { @@ -450,3 +482,55 @@ func TestBSSCIVersionInterop_InboundServiceCenterCommandRejected(t *testing.T) { }) } } + +// TestBSSCIConnectTimeouts drives the handshake deadline state machine over +// real framed traffic (net.Pipe honors read deadlines): each timeout closes +// the socket after provisional cleanup WITHOUT sending an error frame to the +// peer that timed out, and activation clears the deadline entirely. +func TestBSSCIConnectTimeouts(t *testing.T) { + shortTimeouts := func(cfg *Config) { + cfg.ConnectionEstablishmentTimeout = 150 * time.Millisecond + cfg.OperationAckTimeout = 150 * time.Millisecond + } + + t.Run("EstablishmentTimeout", func(t *testing.T) { + // No con arrives within the establishment window: the connection + // closes silently (a Read observing EOF proves no error frame was + // written first) + h := startInteropServerWithConfig(t, EncodingJSON, shortTimeouts) + h.expectClosed() + }) + + t.Run("ConCmpTimeout", func(t *testing.T) { + // conRsp sent but conCmp never arrives: the ack deadline closes the + // connection without an extra error frame + h := startInteropServerWithConfig(t, EncodingJSON, shortTimeouts) + h.writeFrame(connectPayload("1.0.0", uint64(0x70B3D59CD00009E6))) + require.Equal(t, mioty.CmdConnectResponse, frameCommand(h.readFrame())) + h.expectClosed() + }) + + t.Run("ErrorAckTimeout", func(t *testing.T) { + // A connect-stage error was sent but the errorAck never arrives: the + // ack deadline closes the connection + h := startInteropServerWithConfig(t, EncodingJSON, shortTimeouts) + h.writeFrame(map[string]interface{}{"command": mioty.CmdPing, "opId": int64(1)}) + require.Equal(t, mioty.CmdError, frameCommand(h.readFrame())) + h.expectClosed() + }) + + t.Run("ActivationClearsDeadline", func(t *testing.T) { + // After conCmp the session reads without a deadline: an idle period + // far beyond both timeouts must not close the connection + h := startInteropServerWithConfig(t, EncodingJSON, shortTimeouts) + h.writeFrame(connectPayload("1.0.0", uint64(0x70B3D59CD00009E6))) + require.Equal(t, mioty.CmdConnectResponse, frameCommand(h.readFrame())) + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + + time.Sleep(500 * time.Millisecond) + + h.writeFrame(map[string]interface{}{"command": mioty.CmdPing, "opId": int64(1)}) + require.Equal(t, mioty.CmdPingResponse, frameCommand(h.readFrame()), + "an active session must survive idle periods beyond the handshake timeouts") + }) +} diff --git a/KC-Core/pkg/bssci/vm_handlers.go b/KC-Core/pkg/bssci/vm_handlers.go index 80c8088..fa0aeed 100644 --- a/KC-Core/pkg/bssci/vm_handlers.go +++ b/KC-Core/pkg/bssci/vm_handlers.go @@ -3,6 +3,7 @@ package bssci import ( "encoding/binary" "encoding/json" + "errors" "fmt" "time" @@ -413,11 +414,13 @@ func (s *Server) SendVMActivate(sessionID string, epEui uint64, macType uint8) e return fmt.Errorf("%s: %s", ResolveErrorMessage(errSessionNotFound), sessionID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } // Create VM activate message per BSSCI spec vmActivate := map[string]interface{}{ @@ -440,18 +443,16 @@ func (s *Server) SendVMActivate(sessionID string, epEui uint64, macType uint8) e "sessionID", sessionID, "opId", opId, "error", err) + return err } - // Send message with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, vmActivate); err != nil { - // CRITICAL: Rollback operation ID on send failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up pending operation from database on send failure - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both DB and memory cleanup - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingVMOpAfterSendFailure, "sessionID", session.DbSessionID, "opId", opId, @@ -460,15 +461,6 @@ func (s *Server) SendVMActivate(sessionID string, epEui uint64, macType uint8) e return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendVMActivate), err) } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { - s.logger.Error(LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - s.logger.InfoContext(s.sessionContext(session), LogBSSCISentVMActivateCommand, "sessionID", sessionID, "epEui", epEui, @@ -488,11 +480,13 @@ func (s *Server) SendVMDeactivate(sessionID string, epEui uint64, macType uint8) return fmt.Errorf("%s: %s", ResolveErrorMessage(errSessionNotFound), sessionID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } // Create VM deactivate message per BSSCI spec vmDeactivate := map[string]interface{}{ @@ -515,18 +509,16 @@ func (s *Server) SendVMDeactivate(sessionID string, epEui uint64, macType uint8) "sessionID", sessionID, "opId", opId, "error", err) + return err } - // Send message with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, vmDeactivate); err != nil { - // CRITICAL: Rollback operation ID on send failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up pending operation from database on send failure - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both DB and memory cleanup - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingVMOpAfterSendFailure, "sessionID", session.DbSessionID, "opId", opId, @@ -535,15 +527,6 @@ func (s *Server) SendVMDeactivate(sessionID string, epEui uint64, macType uint8) return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendVMDeactivate), err) } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { - s.logger.Error(LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - s.logger.InfoContext(s.sessionContext(session), LogBSSCISentVMDeactivateCommand, "sessionID", sessionID, "epEui", epEui, @@ -563,11 +546,13 @@ func (s *Server) SendVMStatus(sessionID string, epEui uint64) error { return fmt.Errorf("%s: %s", ResolveErrorMessage(errSessionNotFound), sessionID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } // Create VM status message per BSSCI spec vmStatus := map[string]interface{}{ @@ -588,18 +573,16 @@ func (s *Server) SendVMStatus(sessionID string, epEui uint64) error { "sessionID", sessionID, "opId", opId, "error", err) + return err } - // Send message with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, vmStatus); err != nil { - // CRITICAL: Rollback operation ID on send failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up pending operation from database on send failure - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both DB and memory cleanup - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingVMOpAfterSendFailure, "sessionID", session.DbSessionID, "opId", opId, @@ -608,15 +591,6 @@ func (s *Server) SendVMStatus(sessionID string, epEui uint64) error { return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendVMStatus), err) } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { - s.logger.Error(LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - s.logger.InfoContext(s.sessionContext(session), LogBSSCISentVMStatusRequest, "sessionID", sessionID, "epEui", epEui, @@ -635,11 +609,13 @@ func (s *Server) SendVMDownlinkData(sessionID string, epEui uint64, macType uint return fmt.Errorf("%s: %s", ResolveErrorMessage(errSessionNotFound), sessionID) } - // Generate per-session SC operation ID with atomic decrement (BSSCI §5.2) - session.mu.Lock() - session.LastScOpId-- - opId := session.LastScOpId - session.mu.Unlock() + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return err + } // Create VM downlink data message per BSSCI spec // TxTime is optional - only set when needed @@ -686,18 +662,16 @@ func (s *Server) SendVMDownlinkData(sessionID string, epEui uint64, macType uint "sessionID", sessionID, "opId", opId, "error", err) + return err } - // Send message with rollback guard (BSSCI §5.2) if err := s.sendMessage(session, vmDlData); err != nil { - // CRITICAL: Rollback operation ID on send failure - session.mu.Lock() - session.LastScOpId++ - session.mu.Unlock() - - // Clean up pending operation from database on send failure - // BSSCI §§5.11-5.12.3 Gap 1: StatusService handles both DB and memory cleanup - if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingVMOpAfterSendFailure, "sessionID", session.DbSessionID, "opId", opId, @@ -706,15 +680,6 @@ func (s *Server) SendVMDownlinkData(sessionID string, epEui uint64, macType uint return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToSendVMDlData), err) } - // Success - persist counter to DB for session resume - if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { - s.logger.Error(LogBSSCIFailedToUpdateDatabaseSession, - "error", err, - "sessionID", sessionID, - "opId", opId) - // Don't fail the operation - message was sent successfully - } - s.logger.InfoContext(s.sessionContext(session), LogBSSCISentVMDownlinkData, "sessionID", sessionID, "epEui", epEui, diff --git a/KC-Core/pkg/scaci/handler_operations.go b/KC-Core/pkg/scaci/handler_operations.go index 0ddc517..24d874d 100644 --- a/KC-Core/pkg/scaci/handler_operations.go +++ b/KC-Core/pkg/scaci/handler_operations.go @@ -1034,13 +1034,9 @@ func (s *Server) processDLDataQueueCore( schedPosixCode = POSIX_EINVAL } - // Revert downlink status - scheduler failed - // Use orgID (nil-safe pointer) instead of &session.OrganizationID which would filter on uuid.Nil when org not set - statusCtx, statusCancel := context.WithTimeout(ctx, dbconfig.DefaultQueryTimeout) - defer statusCancel() - if err := s.dlSvc.UpdateDownlinkStatus(statusCtx, strconv.FormatInt(stored.QueID, 10), bssci.DLQueueStatusFailed, orgID); err != nil { - s.logger.WarnContext(ctx, LogSCACIUpdateDownlinkStatusFailed, "error", err) - } + // Queue row status is owned by the downlink dispatcher + // (pending → reserved → queued); a scheduler failure leaves the row + // pending so the dlOpen auto-dispatch path can still deliver it. // Mark operation as failed if session.ID > 0 && s.operationRepo != nil { @@ -1056,14 +1052,8 @@ func (s *Server) processDLDataQueueCore( return nil, schedErrToken, schedPosixCode } - // Update status to queued after successful BSSCI coordination - // Use orgID (nil-safe pointer) instead of &session.OrganizationID which would filter on uuid.Nil when org not set - statusCtx, statusCancel := context.WithTimeout(ctx, dbconfig.DefaultQueryTimeout) - defer statusCancel() - if err := s.dlSvc.UpdateDownlinkStatus(statusCtx, strconv.FormatInt(stored.QueID, 10), bssci.DLQueueStatusQueued, orgID); err != nil { - s.logger.WarnContext(ctx, LogSCACIUpdateDownlinkStatusQueued, "error", err) - // Non-critical error, continue - } + // Queue row status is owned by the downlink dispatcher: DispatchQueue has + // already transitioned the row pending → reserved → queued. s.logger.InfoContext(ctx, LogSCACIDLDataQueueProcessed, "opId", opId, diff --git a/KC-Core/pkg/scaci/handler_operations_test.go b/KC-Core/pkg/scaci/handler_operations_test.go index 415507b..779fd9a 100644 --- a/KC-Core/pkg/scaci/handler_operations_test.go +++ b/KC-Core/pkg/scaci/handler_operations_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" "github.com/Kiloiot/kilo-service-center/KC-DB/storage" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" @@ -2018,9 +2017,10 @@ func TestHandleDLDataQueue_NonCntDependMultiPayload_ReturnsEINVAL(t *testing.T) mockRecorder.AssertNotCalled(t, "Record") } -// TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSuccess verifies queued status updates -// target que_id (SCACI queue ID) instead of internal DB row id. -func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSuccess(t *testing.T) { +// TestProcessDLDataQueueCore_NoDirectStatusWriteOnSuccess verifies the SCACI +// handler performs no direct queue status write on success: the downlink +// dispatcher is the single owner of pending → reserved → queued. +func TestProcessDLDataQueueCore_NoDirectStatusWriteOnSuccess(t *testing.T) { const ( tenantID int64 = 1 opID int64 = 101 @@ -2044,8 +2044,6 @@ func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSuccess(t *testing.T) { mockDL.On("QueueDownlink", mock.Anything, mock.MatchedBy(func(req *mioty.DLDataQueue) bool { return req != nil && req.QueId == queID }), tenantID).Return(queID, bsEUI, "") - mockDL.On("UpdateDownlinkStatus", mock.Anything, "7000001", bssci.DLQueueStatusQueued, mock.Anything). - Return(nil).Once() server := &Server{ logger: testLogger(), @@ -2070,13 +2068,16 @@ func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSuccess(t *testing.T) { assert.Equal(t, "", errToken) assert.Equal(t, 0, posixCode) + mockDL.AssertNotCalled(t, "UpdateDownlinkStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) mockDL.AssertExpectations(t) mockEndpoint.AssertExpectations(t) } -// TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSchedulerFailure verifies failed status updates -// target que_id (SCACI queue ID) when scheduler coordination fails. -func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSchedulerFailure(t *testing.T) { +// TestProcessDLDataQueueCore_NoDirectStatusWriteOnSchedulerFailure verifies the +// SCACI handler performs no direct queue status write when scheduler +// coordination fails: the row stays pending so the dlOpen auto-dispatch path +// can still deliver it. +func TestProcessDLDataQueueCore_NoDirectStatusWriteOnSchedulerFailure(t *testing.T) { const ( tenantID int64 = 1 opID int64 = 102 @@ -2099,8 +2100,6 @@ func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSchedulerFailure(t *tes mockDL.On("QueueDownlink", mock.Anything, mock.MatchedBy(func(req *mioty.DLDataQueue) bool { return req != nil && req.QueId == queID }), tenantID).Return(uint64(0), uint64(0), errSchedulerUnavailable) - mockDL.On("UpdateDownlinkStatus", mock.Anything, "7000002", bssci.DLQueueStatusFailed, mock.Anything). - Return(nil).Once() server := &Server{ logger: testLogger(), @@ -2124,6 +2123,7 @@ func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSchedulerFailure(t *tes assert.Equal(t, errSchedulerUnavailable, errToken) assert.Equal(t, POSIX_ENOTSUP, posixCode) + mockDL.AssertNotCalled(t, "UpdateDownlinkStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) mockDL.AssertExpectations(t) mockEndpoint.AssertExpectations(t) } diff --git a/KC-Core/pkg/scheduler/interfaces.go b/KC-Core/pkg/scheduler/interfaces.go index 5a1fa2c..d48c188 100644 --- a/KC-Core/pkg/scheduler/interfaces.go +++ b/KC-Core/pkg/scheduler/interfaces.go @@ -2,6 +2,8 @@ package scheduler import ( + "context" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" ) @@ -48,11 +50,12 @@ type ULTransmitScheduler interface { // - Used by SCACI handlers (KC-Core/pkg/scaci/handler_operations.go) // - No circular dependencies between SCACI and BSSCI type DownlinkScheduler interface { - // QueueDownlink queues a downlink message for delivery to an endpoint. + // QueueDownlink dispatches a persisted pending downlink message to an endpoint. // // Parameters: + // - ctx: Request context for cancellation and tenant metadata // - req: DL data queue request containing endpoint, payload, and delivery options - // - tenantID: Tenant context for base station selection and queue persistence + // - tenantID: Tenant context for base station selection and queue dispatch // // Returns: // - queuedQueId: Actual queue ID assigned (may differ from requested if collision) @@ -62,8 +65,9 @@ type DownlinkScheduler interface { // Error Semantics: // - ErrSchedulerNoResources: No bidirectional BS available (retry later) // - ErrSchedulerResourceMissing: Requested BS/EP not available (permanent for these params) + // - ErrSchedulerQueueNotFound: No matching pending queue row to dispatch // - Other errors: Infrastructure failures (database, network, etc.) - QueueDownlink(req *mioty.DLDataQueue, tenantID int64) (queuedQueId uint64, bsEui uint64, err error) + QueueDownlink(ctx context.Context, req *mioty.DLDataQueue, tenantID int64) (queuedQueId uint64, bsEui uint64, err error) // RevokeDownlink cancels a queued downlink message before delivery (SCACI §3.11). // diff --git a/KC-DB/storage/interfaces/mioty_downlink_repository.go b/KC-DB/storage/interfaces/mioty_downlink_repository.go index 78c5429..09dbbc1 100644 --- a/KC-DB/storage/interfaces/mioty_downlink_repository.go +++ b/KC-DB/storage/interfaces/mioty_downlink_repository.go @@ -30,19 +30,30 @@ type MIOTYDownlinkRepository interface { UpdateDownlinkBaseStation(ctx context.Context, queId uint64, tenantID string, bsEUI uint64) error RevokeDownlink(ctx context.Context, queId int64, tenantID string) error - // Transactional Dispatch Methods (BSSCI §5.10.2) - // These methods are ONLY valid within a transaction context via Transaction.MIOTYDownlinks() - // Non-transactional *DB implementations return ErrNotImplemented + // Dispatch Methods (BSSCI rev1 §5.12 / classic §3.12) // ReserveNextPendingDownlink selects+reserves highest-priority pending downlink for dispatch. - // Uses FOR UPDATE SKIP LOCKED to avoid blocking concurrent dispatchers. + // Uses FOR UPDATE SKIP LOCKED to avoid blocking concurrent dispatchers, so it + // is ONLY valid within a transaction context via Transaction.MIOTYDownlinks(); + // the non-transactional *DB implementation returns ErrNotImplemented. // Returns nil, nil if no pending downlinks available (not an error). // tenantID is int64 (owner tenant, not session tenant for roaming safety). // orgID filters by organization; nil = no org filter (backward compatible) ReserveNextPendingDownlink(ctx context.Context, tenantID int64, epEUI []byte, bsEUI uint64, orgID *uuid.UUID) (*storage.DownlinkMessage, error) + // ReservePendingDownlinkByQueueID atomically reserves one exact pending + // queue row for dispatch. The row transitions pending → reserved only when + // que_id, tenant_id, and ep_eui all match; when organizationID is supplied + // the row's organization_id must match it exactly (a NULL organization row + // never satisfies an org-scoped request). Returns nil, nil when no row in + // 'pending' state matches (already dispatched, revoked, or foreign). + ReservePendingDownlinkByQueueID(ctx context.Context, tenantID int64, organizationID *uuid.UUID, queueID uint64, epEUI []byte, bsEUI uint64) (*storage.DownlinkMessage, error) + // MarkReservedAsQueued transitions reserved → queued with transmission metadata. // Sets status='queued', transmission_time, tx_bs_eui. transmission_result stays NULL. + // Idempotent: a row already in 'queued' state succeeds without modification; + // any other state returns an error. Available on both the regular repository + // and the transactional wrapper. // packetCnt is nullable - pass nil if unknown (set from the dlDataRes transmission result, BSSCI 5.14). // orgID filters by organization; nil = no org filter (backward compatible) MarkReservedAsQueued(ctx context.Context, queID uint64, tenantID int64, bsEUI uint64, txTime int64, packetCnt *uint32, orgID *uuid.UUID) error diff --git a/KC-DB/storage/interfaces/repository.go b/KC-DB/storage/interfaces/repository.go index 6c0c7bf..09057ec 100644 --- a/KC-DB/storage/interfaces/repository.go +++ b/KC-DB/storage/interfaces/repository.go @@ -263,6 +263,13 @@ type PendingOperationRepository interface { // Create inserts or updates a pending operation (UPSERT pattern) Create(ctx context.Context, req *PendingOperationRequest) error + // CreateBatch inserts or updates several pending operations in one local + // transaction. Either every operation is durably recorded or none is, so a + // multi-frame sequence (e.g. dlRxStatQry paired with dlDataQue) never has a + // partially persisted recovery record. Requests are inserted in slice order + // so the id column preserves the intended reissue order. + CreateBatch(ctx context.Context, reqs []*PendingOperationRequest) error + // UpdateMetadata updates only the metadata field UpdateMetadata(ctx context.Context, sessionID int64, operationID int64, metadata json.RawMessage) error diff --git a/KC-DB/storage/postgres/pending_operation_repository.go b/KC-DB/storage/postgres/pending_operation_repository.go index 6f49f9e..7f7674d 100644 --- a/KC-DB/storage/postgres/pending_operation_repository.go +++ b/KC-DB/storage/postgres/pending_operation_repository.go @@ -46,6 +46,46 @@ func (r *pendingOperationRepository) Create(ctx context.Context, req *interfaces return err } +// CreateBatch inserts or updates several pending operations in one local +// transaction so a multi-frame sequence is recorded all-or-nothing. Requests +// are inserted in slice order so the id column preserves reissue order. +func (r *pendingOperationRepository) CreateBatch(ctx context.Context, reqs []*interfaces.PendingOperationRequest) error { + if len(reqs) == 0 { + return nil + } + + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { + _ = tx.Rollback() + }() + + for _, req := range reqs { + var metadataArg interface{} + if req.Metadata != nil { + metadataArg = req.Metadata + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO bssci_pending_operations + (basestation_session_id, operation_id, operation_type, endpoint_eui, operation_data, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (basestation_session_id, operation_id) + DO UPDATE SET + operation_type = EXCLUDED.operation_type, + endpoint_eui = EXCLUDED.endpoint_eui, + operation_data = EXCLUDED.operation_data, + metadata = EXCLUDED.metadata, + updated_at = NOW() + `, req.SessionID, req.OperationID, req.OperationType, req.EndpointEUI, req.OperationData, metadataArg); err != nil { + return err + } + } + + return tx.Commit() +} + // UpdateMetadata updates only the metadata field func (r *pendingOperationRepository) UpdateMetadata(ctx context.Context, sessionID int64, operationID int64, metadata json.RawMessage) error { _, err := r.db.ExecContext(ctx, ` @@ -106,7 +146,7 @@ func (r *pendingOperationRepository) GetBySession(ctx context.Context, sessionID operation_data, metadata, created_at, updated_at FROM bssci_pending_operations WHERE basestation_session_id = $1 - ORDER BY created_at ASC + ORDER BY created_at ASC, id ASC `, sessionID) return ops, err diff --git a/KC-DB/storage/postgres/postgres.go b/KC-DB/storage/postgres/postgres.go index ab8a566..6e8b688 100644 --- a/KC-DB/storage/postgres/postgres.go +++ b/KC-DB/storage/postgres/postgres.go @@ -1246,14 +1246,21 @@ func (w *dbDownlinkWrapper) RevokeDownlink(ctx context.Context, queId int64, ten return (*DB)(w).RevokeDownlink(ctx, queId, tenantID) } -// ReserveNextPendingDownlink is tx-only; use Transaction.MIOTYDownlinks() +// ReserveNextPendingDownlink is tx-only (FOR UPDATE SKIP LOCKED); use Transaction.MIOTYDownlinks() func (w *dbDownlinkWrapper) ReserveNextPendingDownlink(_ context.Context, _ int64, _ []byte, _ uint64, _ *uuid.UUID) (*storage.DownlinkMessage, error) { return nil, ErrNotImplemented } -// MarkReservedAsQueued is tx-only; use Transaction.MIOTYDownlinks() -func (w *dbDownlinkWrapper) MarkReservedAsQueued(_ context.Context, _ uint64, _ int64, _ uint64, _ int64, _ *uint32, _ *uuid.UUID) error { - return ErrNotImplemented +// ReservePendingDownlinkByQueueID reserves one exact pending row as a single +// atomic UPDATE, so it is available outside a transaction context. +func (w *dbDownlinkWrapper) ReservePendingDownlinkByQueueID(ctx context.Context, tenantID int64, organizationID *uuid.UUID, queueID uint64, epEUI []byte, bsEUI uint64) (*storage.DownlinkMessage, error) { + return reservePendingDownlinkByQueueID(ctx, w.conn, tenantID, organizationID, queueID, epEUI, bsEUI) +} + +// MarkReservedAsQueued confirms a reservation as queued (idempotent single +// statement), so it is available outside a transaction context. +func (w *dbDownlinkWrapper) MarkReservedAsQueued(ctx context.Context, queID uint64, tenantID int64, bsEUI uint64, txTime int64, packetCnt *uint32, orgID *uuid.UUID) error { + return markReservedAsQueued(ctx, w.conn, queID, tenantID, bsEUI, txTime, packetCnt, orgID) } // SCACISessions returns the SCACI Session repository diff --git a/KC-DB/storage/postgres/transaction_downlink.go b/KC-DB/storage/postgres/transaction_downlink.go index c650a3f..1890a74 100644 --- a/KC-DB/storage/postgres/transaction_downlink.go +++ b/KC-DB/storage/postgres/transaction_downlink.go @@ -66,28 +66,113 @@ func (r *transactionalMIOTYDownlinkRepository) ReserveNextPendingDownlink( selectArgs = []interface{}{tenantID, epEUI, bssci.DLQueueStatusPending} } + dl, err := scanDownlinkQueueRow(r.tx.QueryRowContext(ctx, selectQuery, selectArgs...)) + if err == sql.ErrNoRows { + return nil, nil // No pending downlinks - not an error + } + if err != nil { + return nil, fmt.Errorf("select pending downlink: %w", err) + } + + // Step 2: Mark as reserved (still within transaction lock) + // Convert bsEUI to bytea + bsEUIBytes := make([]byte, 8) + binary.BigEndian.PutUint64(bsEUIBytes, bsEUI) + + reserveQuery := ` + UPDATE downlink_queue + SET status = $1, bs_eui = $2, updated_at = NOW() + WHERE que_id = $3 AND tenant_id = $4 AND status = $5 + ` + result, err := r.tx.ExecContext(ctx, reserveQuery, + bssci.DLQueueStatusReserved, + bsEUIBytes, + dl.QueID, + tenantID, + bssci.DLQueueStatusPending, + ) + if err != nil { + return nil, fmt.Errorf("reserve downlink: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + // ONLY return this error when row was locked/taken by concurrent tx + return nil, ErrDownlinkAlreadyReserved + } + + dl.Status = bssci.DLQueueStatusReserved + dl.BsEui = bsEUI + return dl, nil +} + +// ReservePendingDownlinkByQueueID atomically reserves one exact pending queue +// row (see interface contract). Shares the single-statement implementation +// with the non-transactional repository. +func (r *transactionalMIOTYDownlinkRepository) ReservePendingDownlinkByQueueID( + ctx context.Context, + tenantID int64, + organizationID *uuid.UUID, + queueID uint64, + epEUI []byte, + bsEUI uint64, +) (*storage.DownlinkMessage, error) { + return reservePendingDownlinkByQueueID(ctx, r.tx, tenantID, organizationID, queueID, epEUI, bsEUI) +} + +// MarkReservedAsQueued transitions reserved → queued with transmission metadata. +// Idempotent per the interface contract; shares the implementation with the +// non-transactional repository. +func (r *transactionalMIOTYDownlinkRepository) MarkReservedAsQueued( + ctx context.Context, + queID uint64, + tenantID int64, + bsEUI uint64, + txTime int64, + packetCnt *uint32, + orgID *uuid.UUID, +) error { + return markReservedAsQueued(ctx, r.tx, queID, tenantID, bsEUI, txTime, packetCnt, orgID) +} + +// sqlExecQuerier is the subset of *sql.Tx / *sql.DB used by the shared +// downlink dispatch statements so the transactional and regular repositories +// share one implementation. +type sqlExecQuerier interface { + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row +} + +// downlinkQueueColumns is the shared column list scanned by +// scanDownlinkQueueRow for dispatch reads and RETURNING clauses. +const downlinkQueueColumns = `id, que_id, ep_eui, tenant_id, organization_id, payload, priority, status, + cnt_depend, packet_cnt, format, response_exp, response_prio, + dl_wind_req, exp_only, dl_rx_stat_qry, user_data, created_at` + +// scanDownlinkQueueRow scans one downlink_queue row (downlinkQueueColumns +// order) into a storage.DownlinkMessage. Returns sql.ErrNoRows unwrapped so +// callers can map "no matching row" to their contract. +func scanDownlinkQueueRow(row *sql.Row) (*storage.DownlinkMessage, error) { var dl storage.DownlinkMessage var epEuiBytes []byte + var rowTenantID int64 var packetCntArray pq.Int64Array var userDataJSON []byte var orgID *uuid.UUID - err := r.tx.QueryRowContext(ctx, selectQuery, selectArgs...).Scan( - &dl.ID, &dl.QueID, &epEuiBytes, &tenantID, &orgID, &dl.Payload, &dl.Priority, + err := row.Scan( + &dl.ID, &dl.QueID, &epEuiBytes, &rowTenantID, &orgID, &dl.Payload, &dl.Priority, &dl.Status, &dl.CntDepend, &packetCntArray, &dl.Format, &dl.ResponseExp, &dl.ResponsePrio, &dl.DlWindReq, &dl.ExpOnly, &dl.DlRxStatQry, &userDataJSON, &dl.CreatedAt, ) - if err == sql.ErrNoRows { - return nil, nil // No pending downlinks - not an error - } if err != nil { - return nil, fmt.Errorf("select pending downlink: %w", err) + return nil, err } // Convert bytea to string for storage struct dl.EPEUI = hex.EncodeToString(epEuiBytes) - dl.TenantID = fmt.Sprintf("%d", tenantID) + dl.TenantID = fmt.Sprintf("%d", rowTenantID) dl.OrganizationID = orgID // Convert packet counter array @@ -108,44 +193,66 @@ func (r *transactionalMIOTYDownlinkRepository) ReserveNextPendingDownlink( // Skip unmarshaling errors to handle NULL or empty JSON gracefully } - // Step 2: Mark as reserved (still within transaction lock) - // Convert bsEUI to bytea + return &dl, nil +} + +// reservePendingDownlinkByQueueID performs the exact-match pending → reserved +// transition as one atomic UPDATE ... RETURNING statement. The row must match +// que_id, tenant_id, and ep_eui and be in 'pending' state; an org-scoped +// request additionally requires organization_id to match exactly (never +// OR organization_id IS NULL). +func reservePendingDownlinkByQueueID( + ctx context.Context, + q sqlExecQuerier, + tenantID int64, + organizationID *uuid.UUID, + queueID uint64, + epEUI []byte, + bsEUI uint64, +) (*storage.DownlinkMessage, error) { bsEUIBytes := make([]byte, 8) binary.BigEndian.PutUint64(bsEUIBytes, bsEUI) - reserveQuery := ` - UPDATE downlink_queue - SET status = $1, bs_eui = $2, updated_at = NOW() - WHERE que_id = $3 AND tenant_id = $4 AND status = $5 - ` - result, err := r.tx.ExecContext(ctx, reserveQuery, + orgFilter := "" + args := []interface{}{ bssci.DLQueueStatusReserved, bsEUIBytes, - dl.QueID, + queueID, tenantID, + epEUI, bssci.DLQueueStatusPending, - ) - if err != nil { - return nil, fmt.Errorf("reserve downlink: %w", err) } - - rows, _ := result.RowsAffected() - if rows == 0 { - // ONLY return this error when row was locked/taken by concurrent tx - return nil, ErrDownlinkAlreadyReserved + if organizationID != nil { + orgFilter = " AND organization_id = $7" + args = append(args, *organizationID) } - dl.Status = bssci.DLQueueStatusReserved + // nolint:gosec // G201: orgFilter is a static string with a parameterized value, not user input + query := fmt.Sprintf(` + UPDATE downlink_queue + SET status = $1, bs_eui = $2, updated_at = NOW() + WHERE que_id = $3 AND tenant_id = $4 AND ep_eui = $5 AND status = $6%s + RETURNING %s + `, orgFilter, downlinkQueueColumns) + + dl, err := scanDownlinkQueueRow(q.QueryRowContext(ctx, query, args...)) + if err == sql.ErrNoRows { + return nil, nil // No matching pending row - not an error + } + if err != nil { + return nil, fmt.Errorf("reserve downlink by queue id: %w", err) + } dl.BsEui = bsEUI - return &dl, nil + return dl, nil } -// MarkReservedAsQueued transitions reserved → queued with transmission metadata -// Aligns with UpdateDownlinkResult pattern from postgres.go -// packetCnt is nullable - pass nil if unknown (set from the dlDataRes transmission result, BSSCI 5.14) -// orgID filters by organization; nil = no org filter (backward compatible) -func (r *transactionalMIOTYDownlinkRepository) MarkReservedAsQueued( +// markReservedAsQueued transitions reserved → queued with transmission +// metadata. Idempotent: a row already 'queued' succeeds unchanged; any other +// state (pending, failed, completed, revoked) is an error because the caller's +// reservation no longer holds. +func markReservedAsQueued( ctx context.Context, + q sqlExecQuerier, queID uint64, tenantID int64, bsEUI uint64, @@ -194,16 +301,32 @@ func (r *transactionalMIOTYDownlinkRepository) MarkReservedAsQueued( WHERE que_id = $5 AND tenant_id = $6 AND status = $7%s `, orgFilter) - result, err := r.tx.ExecContext(ctx, query, args...) + result, err := q.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("mark downlink queued: %w", err) } rows, _ := result.RowsAffected() - if rows == 0 { - return ErrDownlinkAlreadyReserved // Status changed unexpectedly (race) + if rows > 0 { + return nil + } + + // Zero rows: idempotent success when the row is already queued (a repair + // path or crash-recovery retry already confirmed the send); anything else + // means the reservation no longer holds. + var status string + if err := q.QueryRowContext(ctx, ` + SELECT status FROM downlink_queue WHERE que_id = $1 AND tenant_id = $2 + `, queID, tenantID).Scan(&status); err != nil { + if err == sql.ErrNoRows { + return ErrDownlinkAlreadyReserved + } + return fmt.Errorf("mark downlink queued status check: %w", err) + } + if status == bssci.DLQueueStatusQueued { + return nil } - return nil + return ErrDownlinkAlreadyReserved } // Delegate ALL existing interface methods to non-transactional DB From d8feabe694550229a57d05116aaa811092139e87 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:57 +0200 Subject: [PATCH 06/25] feat(bssci): end-to-end certificate identity enforcement CHIRP-3544. A composite resolver maps the TLS client certificate to an identity: an EUI common name resolves against the registered base station, and an org- common name resolves through the deployment's organization resolver. Connect-time enforcement checks the SHA-256 fingerprint with a race-safe backfill from the stored PEM, and strict mode binds the certificate CN to the connecting EUI. --- KC-Core/cmd/kilocenter/builders/core.go | 10 +- KC-Core/cmd/kilocenter/builders/protocol.go | 10 + KC-Core/internal/grpc/core_service_certs.go | 22 +- KC-Core/internal/grpc/core_service_test.go | 2 +- .../internal/services/bssci/cert_identity.go | 129 +++++++++ .../services/bssci/cert_identity_test.go | 129 +++++++++ .../internal/services/bssci/test_helpers.go | 4 + .../internal/services/certificates/service.go | 252 +++++++++--------- .../services/certificates/service_test.go | 232 +++++++++++++++- KC-Core/pkg/bssci/cert_enforcement_test.go | 231 ++++++++++++++++ KC-Core/pkg/bssci/connect_handler_test.go | 4 + KC-Core/pkg/bssci/contracts.go | 46 ++++ KC-Core/pkg/bssci/log_messages.go | 10 + KC-Core/pkg/bssci/server.go | 180 +++++++++++-- KC-Core/pkg/bssci/status_handlers_test.go | 16 ++ KC-Core/pkg/crypto/fingerprint.go | 32 +++ KC-Core/pkg/grpc/errors_catalog.go | 35 +++ .../interfaces/basestation_repository.go | 6 + .../postgres/basestation_repository.go | 21 ++ 19 files changed, 1208 insertions(+), 163 deletions(-) create mode 100644 KC-Core/internal/services/bssci/cert_identity.go create mode 100644 KC-Core/internal/services/bssci/cert_identity_test.go create mode 100644 KC-Core/pkg/bssci/cert_enforcement_test.go create mode 100644 KC-Core/pkg/crypto/fingerprint.go diff --git a/KC-Core/cmd/kilocenter/builders/core.go b/KC-Core/cmd/kilocenter/builders/core.go index b7ac81a..fd6da94 100644 --- a/KC-Core/cmd/kilocenter/builders/core.go +++ b/KC-Core/cmd/kilocenter/builders/core.go @@ -147,10 +147,12 @@ func BuildCoreService(ctx context.Context, infra *Infrastructure, protocol *Prot coreService = coreService.WithScaciMonitoringService(scaciMonitoringSvc) log.Info("ScaciMonitoringService wired") - // CertificateService - certificateSvc := certificatesservice.New(cfg, infra.LoggerIface). - WithBaseStationRepo(infra.Storage.BaseStations()). - WithKeyEncryptor(infra.KeyEncryptor) + // CertificateService: repository and encryptor are mandatory constructor + // inputs (ownership verification and persistence are part of issuance) + certificateSvc, certErr := certificatesservice.New(cfg, infra.LoggerIface, infra.Storage.BaseStations(), infra.KeyEncryptor, nil) + if certErr != nil { + log.Fatal(LogFailedCreateCoreService, logger.Err(certErr)) + } coreService = coreService.WithCertificateService(certificateSvc) log.Info("CertificateService wired") diff --git a/KC-Core/cmd/kilocenter/builders/protocol.go b/KC-Core/cmd/kilocenter/builders/protocol.go index b3edf42..35448d0 100644 --- a/KC-Core/cmd/kilocenter/builders/protocol.go +++ b/KC-Core/cmd/kilocenter/builders/protocol.go @@ -150,6 +150,16 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol return nil, fmt.Errorf("failed to create BSSCI server: %w (StatusService is mandatory for pending operation tracking)", err) } + // Certificate identity: the CE composite resolver handles EUI CNs against + // the registered stations and delegates org- CNs to the deployment's + // org resolver; the directory backs connect-time fingerprint enforcement + bssciServer.SetCertificateIdentityResolver(bssciservices.NewCertificateIdentityResolver( + bssciInfra.BasestationRepo, + bssciInfra.OrgResolver, + infra.LoggerIface, + )) + bssciServer.SetBaseStationDirectory(bssciservices.NewRegisteredBaseStationDirectory(bssciInfra.BasestationRepo)) + // Create propagation service (depends on Server as AttachPropagateSender) propagationSvc := bssciservices.NewPropagationService( bssciInfra.EndpointRepo, diff --git a/KC-Core/internal/grpc/core_service_certs.go b/KC-Core/internal/grpc/core_service_certs.go index 1fedd4f..159a9d8 100644 --- a/KC-Core/internal/grpc/core_service_certs.go +++ b/KC-Core/internal/grpc/core_service_certs.go @@ -54,12 +54,18 @@ func (s *CoreService) GenerateCertificate(ctx context.Context, req *pb.GenerateC if err != nil { s.log.ErrorContext(ctx, "generate certificate failed", "bs_eui", req.BsEui, "error", err) - // Detect missing server CA and return an actionable error - errStr := err.Error() - if strings.Contains(errStr, grpcerrors.ErrTokenCACertReadFailed) || - strings.Contains(errStr, grpcerrors.ErrTokenCAKeyReadFailed) { - return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCACertReadFailed), - grpcerrors.MsgServerCertRequiredBeforeBS) + // Typed catalog errors survive the service boundary: match on the + // token, never on error text + if token, ok := grpcerrors.TokenOf(err); ok { + switch token { + case grpcerrors.ErrTokenCACertReadFailed, grpcerrors.ErrTokenCAKeyReadFailed: + // Missing server CA gets the actionable setup message + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCACertReadFailed), + grpcerrors.MsgServerCertRequiredBeforeBS) + default: + return nil, status.Error(grpcerrors.GetGRPCCode(token), + grpcerrors.ResolveErrorMessage(token)) + } } return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCertGenerationFailed), @@ -130,7 +136,7 @@ func (s *CoreService) DownloadCertificate(ctx context.Context, req *pb.DownloadC if err != nil { s.log.ErrorContext(ctx, grpcerrors.LogDownloadCertFailed, "cert_type", req.CertType, "cert_id", req.Id, "error", err) // Preserve service-level error tokens (don't mask invalid cert_type) - if strings.Contains(err.Error(), grpcerrors.ErrTokenCertTypeRequired) { + if token, ok := grpcerrors.TokenOf(err); ok && token == grpcerrors.ErrTokenCertTypeRequired { return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCertTypeRequired), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenCertTypeRequired)) } @@ -300,7 +306,7 @@ func (s *CoreService) DownloadBaseStationCertificate(ctx context.Context, req *p return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenServiceNotConfigured), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenServiceNotConfigured)) } - if strings.Contains(err.Error(), grpcerrors.ErrTokenCertTypeRequired) { + if token, ok := grpcerrors.TokenOf(err); ok && token == grpcerrors.ErrTokenCertTypeRequired { return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCertTypeRequired), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenCertTypeRequired)) } diff --git a/KC-Core/internal/grpc/core_service_test.go b/KC-Core/internal/grpc/core_service_test.go index 7f1c1d9..c7bd9c6 100644 --- a/KC-Core/internal/grpc/core_service_test.go +++ b/KC-Core/internal/grpc/core_service_test.go @@ -2627,7 +2627,7 @@ func TestDownloadBaseStationCertificate_InvalidCertType(t *testing.T) { storedFunc: func(_ context.Context, _ int64, _ []byte, certType string) ([]byte, string, error) { // Service rejects invalid cert types if certType != grpcerrors.CertTypeCA && certType != grpcerrors.CertTypeClient && certType != grpcerrors.CertTypeKey { - return nil, "", fmt.Errorf("%s", grpcerrors.ErrTokenCertTypeRequired) + return nil, "", grpcerrors.NewTokenError(grpcerrors.ErrTokenCertTypeRequired, nil) } return nil, "", nil }, diff --git a/KC-Core/internal/services/bssci/cert_identity.go b/KC-Core/internal/services/bssci/cert_identity.go new file mode 100644 index 0000000..0f09fbf --- /dev/null +++ b/KC-Core/internal/services/bssci/cert_identity.go @@ -0,0 +1,129 @@ +package bssciservices + +import ( + "context" + "crypto/x509" + "encoding/binary" + "fmt" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/org" + "github.com/Kiloiot/kilo-service-center/KC-DB/common/validation" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" +) + +// certificateIdentityResolver is the CE composite implementation of +// bssci.CertificateIdentityResolver: a CN that parses as an EUI-64 (the CE +// issuance scheme, dashed uppercase) resolves against the registered base +// stations; any other CN form (org-) is delegated to the deployment's +// organization resolver, so the ECE remote resolver keeps its existing +// contract untouched. +type certificateIdentityResolver struct { + bsRepo interfaces.BaseStationRepository + orgResolver org.Resolver + logger logger.Logger +} + +// NewCertificateIdentityResolver builds the CE composite resolver. +func NewCertificateIdentityResolver( + bsRepo interfaces.BaseStationRepository, + orgResolver org.Resolver, + log logger.Logger, +) bssci.CertificateIdentityResolver { + return &certificateIdentityResolver{ + bsRepo: bsRepo, + orgResolver: orgResolver, + logger: log, + } +} + +// ResolveCertificateIdentity resolves the identity asserted by the client +// certificate CN. EUI CNs are resolved by a global base-station lookup (the +// tenant is not yet authenticated at TLS accept) with the tenant's default +// organization; other CN forms delegate to the organization resolver. +func (r *certificateIdentityResolver) ResolveCertificateIdentity(ctx context.Context, cert *x509.Certificate) (bssci.CertificateIdentity, error) { + cn := cert.Subject.CommonName + + if eui, euiErr := validation.ParseEUI(cn); euiErr == nil { + if r.bsRepo == nil { + return bssci.CertificateIdentity{}, fmt.Errorf("certificate EUI CN %q: base station repository unavailable", cn) + } + euiBytes := binary.BigEndian.AppendUint64(nil, eui) + bs, err := r.bsRepo.GetByEUIGlobal(ctx, euiBytes) + if err != nil || bs == nil { + return bssci.CertificateIdentity{}, fmt.Errorf("certificate EUI CN %q: no registered base station: %w", cn, err) + } + + identity := bssci.CertificateIdentity{TenantID: bs.TenantID, SubjectEUI: &eui} + if r.orgResolver != nil { + orgID, orgErr := r.orgResolver.GetDefaultOrgForTenant(ctx, bs.TenantID) + if orgErr != nil { + r.logger.WarnContext(ctx, bssci.LogBSSCICertIdentityDefaultOrgLookupFailed, + "tenantID", bs.TenantID, + "error", orgErr) + } else { + identity.OrganizationID = orgID + } + } + return identity, nil + } + + if r.orgResolver == nil { + return bssci.CertificateIdentity{}, fmt.Errorf("certificate CN %q: no organization resolver configured", cn) + } + orgID, tenantID, err := r.orgResolver.ResolveCert(ctx, cert) + if err != nil { + return bssci.CertificateIdentity{}, err + } + return bssci.CertificateIdentity{OrganizationID: orgID, TenantID: tenantID}, nil +} + +// registeredBaseStationDirectory adapts the base-station repository to the +// narrow bssci.RegisteredBaseStationDirectory read/backfill contract. +type registeredBaseStationDirectory struct { + bsRepo interfaces.BaseStationRepository +} + +// NewRegisteredBaseStationDirectory builds the repository-backed directory. +func NewRegisteredBaseStationDirectory(bsRepo interfaces.BaseStationRepository) bssci.RegisteredBaseStationDirectory { + return ®isteredBaseStationDirectory{bsRepo: bsRepo} +} + +// GetGlobal returns the registration identity for an EUI across all tenants. +func (d *registeredBaseStationDirectory) GetGlobal(ctx context.Context, eui uint64) (bssci.RegisteredBaseStation, error) { + euiBytes := binary.BigEndian.AppendUint64(nil, eui) + bs, err := d.bsRepo.GetByEUIGlobal(ctx, euiBytes) + if err != nil { + return bssci.RegisteredBaseStation{}, err + } + if bs == nil { + return bssci.RegisteredBaseStation{}, fmt.Errorf("base station %016X not registered", eui) + } + + registered := bssci.RegisteredBaseStation{ + ID: bs.ID, + TenantID: bs.TenantID, + EUI: eui, + Name: bs.Name, + } + if bs.TLSCertificate != nil { + registered.TLSCertificate = *bs.TLSCertificate + } + if bs.TLSCertFingerprint != nil { + registered.TLSCertFingerprint = *bs.TLSCertFingerprint + } + return registered, nil +} + +// BackfillFingerprintIfBlank persists the fingerprint only while the stored +// value is still blank (conditional SQL; see repository contract). +func (d *registeredBaseStationDirectory) BackfillFingerprintIfBlank(ctx context.Context, tenantID, id int64, fingerprint string) (bool, error) { + return d.bsRepo.UpdateTLSFingerprintIfBlank(ctx, tenantID, id, fingerprint) +} + +// interface guards +var ( + _ bssci.CertificateIdentityResolver = (*certificateIdentityResolver)(nil) + _ bssci.RegisteredBaseStationDirectory = (*registeredBaseStationDirectory)(nil) +) diff --git a/KC-Core/internal/services/bssci/cert_identity_test.go b/KC-Core/internal/services/bssci/cert_identity_test.go new file mode 100644 index 0000000..1baeb43 --- /dev/null +++ b/KC-Core/internal/services/bssci/cert_identity_test.go @@ -0,0 +1,129 @@ +package bssciservices + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeTestCert creates a real self-signed x509 certificate with the given CN. +func makeTestCert(t *testing.T, cn string) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +// certIdentityBSRepo overrides the global lookup of the fieldless base +// mock with configurable results. +type certIdentityBSRepo struct { + mockBaseStationRepo + baseStation *models.BaseStation + getErr error +} + +func (r *certIdentityBSRepo) GetByEUIGlobal(_ context.Context, _ []byte) (*models.BaseStation, error) { + if r.getErr != nil { + return nil, r.getErr + } + return r.baseStation, nil +} + +// certIdentityOrgResolver extends the ingest fake with delegation tracking. +type certIdentityOrgResolver struct { + fakeOrgResolver + resolveCertOrg uuid.UUID + resolveCertTenant int64 + resolveCertErr error + resolveCertCalls int +} + +func (r *certIdentityOrgResolver) ResolveCert(_ context.Context, _ *x509.Certificate) (uuid.UUID, int64, error) { + r.resolveCertCalls++ + if r.resolveCertErr != nil { + return uuid.Nil, 0, r.resolveCertErr + } + return r.resolveCertOrg, r.resolveCertTenant, nil +} + +// TestCertIdentity_EUICN_ResolvesRegisteredStation: a dashed-EUI CN (the CE +// issuance scheme) resolves via the global station lookup and carries the +// subject EUI for connect-time binding. +func TestCertIdentity_EUICN_ResolvesRegisteredStation(t *testing.T) { + const eui = uint64(0xCAFECAFECAFECAFE) + orgID := uuid.New() + repo := &certIdentityBSRepo{ + baseStation: &models.BaseStation{ID: 7, TenantID: 42, Name: "CE BS"}, + } + orgResolver := &certIdentityOrgResolver{ + fakeOrgResolver: fakeOrgResolver{defaultOrgByTenant: map[int64]uuid.UUID{42: orgID}}, + } + resolver := NewCertificateIdentityResolver(repo, orgResolver, &mockLoggerForDispatch{}) + + identity, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "CA-FE-CA-FE-CA-FE-CA-FE")) + + require.NoError(t, err) + assert.Equal(t, int64(42), identity.TenantID) + assert.Equal(t, orgID, identity.OrganizationID) + require.NotNil(t, identity.SubjectEUI, "an EUI CN must carry the subject EUI") + assert.Equal(t, eui, *identity.SubjectEUI) + assert.Zero(t, orgResolver.resolveCertCalls, "EUI CNs never delegate to ResolveCert") +} + +// TestCertIdentity_EUICN_UnregisteredStationRejected: an EUI CN with no +// registered station cannot resolve. +func TestCertIdentity_EUICN_UnregisteredStationRejected(t *testing.T) { + repo := &certIdentityBSRepo{getErr: errors.New("not found")} + resolver := NewCertificateIdentityResolver(repo, &certIdentityOrgResolver{}, &mockLoggerForDispatch{}) + + _, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "CA-FE-CA-FE-CA-FE-CA-FE")) + + require.Error(t, err, "an unregistered EUI CN must not resolve") +} + +// TestCertIdentity_OrgCN_DelegatesToOrgResolver: a legacy org- CN +// delegates to the deployment's organization resolver unchanged and carries +// no subject EUI. +func TestCertIdentity_OrgCN_DelegatesToOrgResolver(t *testing.T) { + orgID := uuid.New() + orgResolver := &certIdentityOrgResolver{resolveCertOrg: orgID, resolveCertTenant: 9} + resolver := NewCertificateIdentityResolver(&mockBaseStationRepo{}, orgResolver, &mockLoggerForDispatch{}) + + identity, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "org-"+orgID.String())) + + require.NoError(t, err) + assert.Equal(t, 1, orgResolver.resolveCertCalls, "org CNs delegate to ResolveCert") + assert.Equal(t, orgID, identity.OrganizationID) + assert.Equal(t, int64(9), identity.TenantID) + assert.Nil(t, identity.SubjectEUI, "org CNs carry no station identity") +} + +// TestCertIdentity_OrgCN_DelegateFailurePropagates: a delegated resolution +// failure surfaces (strict mode closes the connection on it). +func TestCertIdentity_OrgCN_DelegateFailurePropagates(t *testing.T) { + orgResolver := &certIdentityOrgResolver{resolveCertErr: errors.New("unknown org")} + resolver := NewCertificateIdentityResolver(&mockBaseStationRepo{}, orgResolver, &mockLoggerForDispatch{}) + + _, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "org-unknown")) + + require.Error(t, err) +} diff --git a/KC-Core/internal/services/bssci/test_helpers.go b/KC-Core/internal/services/bssci/test_helpers.go index 3b59d94..1a59d1a 100644 --- a/KC-Core/internal/services/bssci/test_helpers.go +++ b/KC-Core/internal/services/bssci/test_helpers.go @@ -927,3 +927,7 @@ func CreateTestServices(log logger.Logger, eventStore interfaces.SystemEventStor return sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStore } + +func (m *mockBaseStationRepo) UpdateTLSFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + return true, nil +} diff --git a/KC-Core/internal/services/certificates/service.go b/KC-Core/internal/services/certificates/service.go index ee5fea6..df97072 100644 --- a/KC-Core/internal/services/certificates/service.go +++ b/KC-Core/internal/services/certificates/service.go @@ -4,12 +4,12 @@ package certificates import ( "bytes" "context" - "crypto/sha256" "crypto/x509" "encoding/binary" "encoding/hex" "encoding/json" "encoding/pem" + "errors" "fmt" "net" "os" @@ -29,6 +29,32 @@ import ( "github.com/google/uuid" ) +// KeyEncryptor is the narrow key-encryption contract the certificate service +// consumes (implemented by crypto.KeyEncryptor). +type KeyEncryptor interface { + EncryptKey(key []byte) (string, error) + DecryptKey(encrypted string) ([]byte, error) +} + +// CertGenRunner executes the certificate generator with the given arguments. +// Injected so tests can produce real PEM material without the external +// certgen binary; the default runner shells out to the configured path. +type CertGenRunner func(ctx context.Context, certGenPath string, args ...string) (stdout, stderr string, err error) + +// execCertGen is the production CertGenRunner: it runs the certgen binary, +// reporting a typed generator-not-found error when the binary is absent. +func execCertGen(ctx context.Context, certGenPath string, args ...string) (string, string, error) { + if _, err := os.Stat(certGenPath); err != nil { + return "", "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertGeneratorNotFound, errors.New(certGenPath)) + } + cmd := exec.CommandContext(ctx, certGenPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + // Service implements grpcservices.CertificateService. type Service struct { config *config.Config @@ -39,11 +65,22 @@ type Service struct { serverValidityDays int protocolConfig *config.ProtocolConfig bsRepo interfaces.BaseStationRepository - keyEncryptor *crypto.KeyEncryptor + keyEncryptor KeyEncryptor + certGen CertGenRunner } -// New creates a new certificate service. -func New(cfg *config.Config, log logger.Logger) *Service { +// New creates a new certificate service. The base-station repository and key +// encryptor are mandatory: ownership verification and durable persistence are +// part of issuance, so an incompletely wired service must never be able to +// mint a certificate. certGen may be nil (defaults to running the configured +// certgen binary). +func New(cfg *config.Config, log logger.Logger, bsRepo interfaces.BaseStationRepository, keyEncryptor KeyEncryptor, certGen CertGenRunner) (*Service, error) { + if bsRepo == nil || keyEncryptor == nil { + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenServiceNotConfigured, nil) + } + if certGen == nil { + certGen = execCertGen + } ctx := context.Background() // Use certificate config paths if available, fall back to constants @@ -98,19 +135,10 @@ func New(cfg *config.Config, log logger.Logger) *Service { tempDir: tempDir, serverValidityDays: serverValidityDays, protocolConfig: &cfg.Protocol, - } -} - -// WithBaseStationRepo adds TLS persistence capability. -func (s *Service) WithBaseStationRepo(repo interfaces.BaseStationRepository) *Service { - s.bsRepo = repo - return s -} - -// WithKeyEncryptor adds private key encryption capability. -func (s *Service) WithKeyEncryptor(enc *crypto.KeyEncryptor) *Service { - s.keyEncryptor = enc - return s + bsRepo: bsRepo, + keyEncryptor: keyEncryptor, + certGen: certGen, + }, nil } // GenerateCertificate generates a new certificate for a base station. @@ -124,7 +152,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer euiValue, euiErr := validation.ParseEUI(req.BsEUI) if euiErr != nil { s.logger.WarnContext(ctx, pkggrpc.LogCertInvalidEUI, "bs_eui", req.BsEUI, "error", euiErr) - return nil, fmt.Errorf("%s: %s", pkggrpc.ErrTokenInvalidBasestationEUIFormat, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenInvalidBasestationEUIFormat)) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenInvalidBasestationEUIFormat, nil) } // Canonical uppercase-dashed form used as the certificate CN and in stored metadata bsEUIDashed := mioty.FormatEUI64Dashed(euiValue) @@ -132,20 +160,26 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer // Validate validity days (max 3 years = 1095 days) if req.ValidityDays < 1 || req.ValidityDays > 1095 { s.logger.WarnContext(ctx, pkggrpc.LogCertInvalidValidityDays, "validity_days", req.ValidityDays) - return nil, fmt.Errorf("%s: %s", pkggrpc.ErrTokenInvalidValidityPeriod, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenInvalidValidityPeriod)) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenInvalidValidityPeriod, nil) + } + + // Certificate issuance is tenant-scoped: without a tenant, a certificate + // could be minted for any EUI. Fail closed. + if req.TenantID <= 0 { + s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, + "bs_eui", req.BsEUI, "tenant_id", req.TenantID) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenMissingTenantCtx, nil) } // Tenant ownership must be verified BEFORE any file or certgen work: the // requesting tenant must already own a base station registered with this // EUI, or a certificate could be minted for another tenant's station // (cross-tenant impersonation). Fail closed. - if s.bsRepo != nil && req.TenantID > 0 { - euiBytes := binary.BigEndian.AppendUint64(nil, euiValue) - if _, ownErr := s.bsRepo.GetByEUI(ctx, req.TenantID, euiBytes); ownErr != nil { - s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, - "bs_eui", req.BsEUI, "tenant_id", req.TenantID, "error", ownErr) - return nil, fmt.Errorf("%s", pkggrpc.ErrTokenBaseStationNotFound) - } + euiBytes := binary.BigEndian.AppendUint64(nil, euiValue) + if _, ownErr := s.bsRepo.GetByEUI(ctx, req.TenantID, euiBytes); ownErr != nil { + s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, + "bs_eui", req.BsEUI, "tenant_id", req.TenantID, "error", ownErr) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenBaseStationNotFound, ownErr) } // Generate unique ID for this certificate set @@ -156,18 +190,10 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer // Create temporary directory for certificates if err := os.MkdirAll(certDir, 0750); err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirCreateFailed, "error", err) - return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCertDirCreateFailed, err) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenCertDirCreateFailed, err) } - // Log the certgen path and check if it exists s.logger.InfoContext(ctx, pkggrpc.LogCertGeneratorPathInfo, "path", s.certGenPath) - if _, err := os.Stat(s.certGenPath); err != nil { - s.logger.ErrorContext(ctx, pkggrpc.LogCertGeneratorNotFound, "path", s.certGenPath, "error", err) - if rmErr := os.RemoveAll(certDir); rmErr != nil { - s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) - } - return nil, fmt.Errorf("%s: %s", pkggrpc.ErrTokenCertGeneratorNotFound, s.certGenPath) - } // Copy the existing CA certificate from KC-Core instead of generating a new one kcCoreCertsPath := s.certsDir @@ -181,7 +207,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer if rmErr := os.RemoveAll(certDir); rmErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) } - return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCACertReadFailed, err) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenCACertReadFailed, err) } if err := os.WriteFile(caCertDst, caCertData, 0600); err != nil { //nolint:gosec // G703: path built from configured cert dir and canonically validated EUI @@ -189,7 +215,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer if rmErr := os.RemoveAll(certDir); rmErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) } - return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCACertCopyFailed, err) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenCACertCopyFailed, err) } s.logger.InfoContext(ctx, pkggrpc.LogCertCACertCopied, "from", caCertSrc, "to", caCertDst) @@ -204,7 +230,7 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer if rmErr := os.RemoveAll(certDir); rmErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) } - return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCAKeyReadFailed, err) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenCAKeyReadFailed, err) } if err := os.WriteFile(caKeyDst, caKeyData, 0600); err != nil { //nolint:gosec // G703: path built from configured cert dir and canonically validated EUI @@ -212,35 +238,34 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer if rmErr := os.RemoveAll(certDir); rmErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) } - return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCAKeyCopyFailed, err) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenCAKeyCopyFailed, err) } - // Execute certificate generation command with -client-only flag - cmd := exec.Command(s.certGenPath, + // Execute certificate generation with -client-only flag + genArgs := []string{ "-dir", certDir, "-days", fmt.Sprintf("%d", req.ValidityDays), "-client", bsEUIDashed, "-client-only", - ) - - s.logger.InfoContext(ctx, pkggrpc.LogCertGenerationExecuting, "command", s.certGenPath, "args", cmd.Args[1:]) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + } + s.logger.InfoContext(ctx, pkggrpc.LogCertGenerationExecuting, "command", s.certGenPath, "args", genArgs) - if err := cmd.Run(); err != nil { + stdout, stderr, err := s.certGen(ctx, s.certGenPath, genArgs...) + if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertGenerationFailed, "error", err) - s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout.String()) - s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStderr, "output", stderr.String()) + s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout) + s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStderr, "output", stderr) if rmErr := os.RemoveAll(certDir); rmErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) } - return nil, fmt.Errorf("%s: %s", pkggrpc.ErrTokenCertGenerationFailed, stderr.String()) + if _, typed := pkggrpc.TokenOf(err); typed { + return nil, err + } + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenCertGenerationFailed, fmt.Errorf("%s: %w", stderr, err)) } s.logger.InfoContext(ctx, pkggrpc.LogCertGenerationSuccess) - s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout.String()) + s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout) // Read the generated certificate to extract expiry date certPath := filepath.Join(certDir, "client.crt") @@ -274,17 +299,12 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer // encrypted key cannot be durably recorded on the base station, no // certificate is returned and the temporary artifacts are removed, so a // station never receives a certificate the service center did not persist. - if s.bsRepo != nil && s.keyEncryptor != nil && req.TenantID > 0 { - euiBytes := binary.BigEndian.AppendUint64(nil, euiValue) - if persistErr := s.persistCertsToBaseStation(ctx, certDir, euiBytes, req.TenantID, certExpiryTime); persistErr != nil { - s.logger.ErrorContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", persistErr) - if rmErr := os.RemoveAll(certDir); rmErr != nil { - s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) - } - return nil, fmt.Errorf("%s: %w", pkggrpc.ErrTokenCertPersistenceFailed, persistErr) + if persistErr := s.persistCertsToBaseStation(ctx, certDir, euiBytes, req.TenantID, certExpiryTime); persistErr != nil { + s.logger.ErrorContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", persistErr) + if rmErr := os.RemoveAll(certDir); rmErr != nil { + s.logger.ErrorContext(ctx, pkggrpc.LogCertDirRemoveFailed, "error", rmErr) } - } else if s.bsRepo != nil && s.keyEncryptor == nil && req.TenantID > 0 { - s.logger.WarnContext(ctx, pkggrpc.LogCertPersistenceSkipped, "error", pkggrpc.ErrTokenServiceNotConfigured) + return nil, pkggrpc.NewTokenError(pkggrpc.ErrTokenCertPersistenceFailed, persistErr) } // Build canonical Service Center URL from config @@ -347,7 +367,7 @@ func (s *Service) DownloadCertificateByID(ctx context.Context, certType, certID downloadName = fmt.Sprintf("basestation-%s-private-key.key", euiPart) default: s.logger.ErrorContext(ctx, pkggrpc.LogDownloadCertFailed, "cert_type", certType, "error", "invalid cert type") - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenCertTypeRequired) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertTypeRequired, nil) } // Build file path @@ -355,13 +375,13 @@ func (s *Service) DownloadCertificateByID(ctx context.Context, certType, certID // Check if file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { - return nil, "", fmt.Errorf("%s: %s", pkggrpc.ErrTokenCertNotFound, certType) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertNotFound, errors.New(certType)) } // Read file data, err := os.ReadFile(filePath) // #nosec G304 - validated certType and UUID certID if err != nil { - return nil, "", fmt.Errorf("%s: %w", pkggrpc.ErrTokenCertNotFound, err) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertNotFound, err) } // If this was the private key, schedule cleanup @@ -411,34 +431,27 @@ func (s *Service) GenerateServerCertificates(ctx context.Context) error { // Ensure directory exists if err := os.MkdirAll(kcCorePath, 0750); err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertsDirCreateFailed, "error", err) - return fmt.Errorf("%s: %w", pkggrpc.ErrTokenCertDirCreateFailed, err) - } - - // Check if certgen exists - if _, err := os.Stat(s.certGenPath); err != nil { - s.logger.ErrorContext(ctx, pkggrpc.LogCertGeneratorNotFound, "path", s.certGenPath, "error", err) - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCertGeneratorNotFound, s.certGenPath) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertDirCreateFailed, err) } - // Execute certificate generation command for server certificates + // Execute certificate generation for server certificates serverHostname := s.deriveServerHostname() - cmd := exec.Command(s.certGenPath, + genArgs := []string{ "-dir", kcCorePath, "-days", fmt.Sprintf("%d", s.serverValidityDays), "-server", serverHostname, - ) - - s.logger.InfoContext(ctx, pkggrpc.LogServerCertGenExecuting, "command", s.certGenPath, "args", cmd.Args[1:], "hostname", serverHostname) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + } + s.logger.InfoContext(ctx, pkggrpc.LogServerCertGenExecuting, "command", s.certGenPath, "args", genArgs, "hostname", serverHostname) - if err := cmd.Run(); err != nil { + stdout, stderr, err := s.certGen(ctx, s.certGenPath, genArgs...) + if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogServerCertGenFailed, "error", err) - s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout.String()) - s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStderr, "output", stderr.String()) - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCertServerGenerationFailed, stderr.String()) + s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout) + s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStderr, "output", stderr) + if _, typed := pkggrpc.TokenOf(err); typed { + return err + } + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertServerGenerationFailed, fmt.Errorf("%s: %w", stderr, err)) } s.logger.InfoContext(ctx, pkggrpc.LogServerCertGenSuccess) @@ -453,43 +466,36 @@ func (s *Service) RenewServerCertificates(ctx context.Context) error { // Require existing server certificate (nothing to renew otherwise) if _, err := os.Stat(filepath.Join(kcCorePath, "server.crt")); os.IsNotExist(err) { - return fmt.Errorf("%s", pkggrpc.ErrTokenNoCertsToRenew) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenNoCertsToRenew, nil) } // Require CA files (-server-only needs them to sign) if _, err := os.Stat(filepath.Join(kcCorePath, "ca.crt")); os.IsNotExist(err) { - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCACertReadFailed, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenCACertReadFailed)) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCACertReadFailed, nil) } if _, err := os.Stat(filepath.Join(kcCorePath, "ca.key")); os.IsNotExist(err) { - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCAKeyReadFailed, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenCAKeyReadFailed)) - } - - // Check if certgen exists - if _, err := os.Stat(s.certGenPath); err != nil { - s.logger.ErrorContext(ctx, pkggrpc.LogCertGeneratorNotFound, "path", s.certGenPath, "error", err) - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCertGeneratorNotFound, s.certGenPath) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCAKeyReadFailed, nil) } // Execute certgen with -server-only to regenerate server cert without touching the CA serverHostname := s.deriveServerHostname() - cmd := exec.Command(s.certGenPath, + genArgs := []string{ "-dir", kcCorePath, "-days", fmt.Sprintf("%d", s.serverValidityDays), "-server", serverHostname, "-server-only", - ) - - s.logger.InfoContext(ctx, pkggrpc.LogServerCertGenExecuting, "command", s.certGenPath, "args", cmd.Args[1:], "hostname", serverHostname) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + } + s.logger.InfoContext(ctx, pkggrpc.LogServerCertGenExecuting, "command", s.certGenPath, "args", genArgs, "hostname", serverHostname) - if err := cmd.Run(); err != nil { + stdout, stderr, err := s.certGen(ctx, s.certGenPath, genArgs...) + if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogServerCertGenFailed, "error", err) - s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout.String()) - s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStderr, "output", stderr.String()) - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCertServerGenerationFailed, stderr.String()) + s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStdout, "output", stdout) + s.logger.DebugContext(ctx, pkggrpc.LogCertGenerationStderr, "output", stderr) + if _, typed := pkggrpc.TokenOf(err); typed { + return err + } + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertServerGenerationFailed, fmt.Errorf("%s: %w", stderr, err)) } s.logger.InfoContext(ctx, pkggrpc.LogServerCertGenSuccess) @@ -605,19 +611,19 @@ func (s *Service) getCertificateInfo(certPath string) *certInfo { // GetStoredCertificate retrieves TLS certificates stored in base station record. func (s *Service) GetStoredCertificate(ctx context.Context, tenantID int64, bsEui []byte, certType string) ([]byte, string, error) { if s.bsRepo == nil { - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenServiceNotConfigured) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenServiceNotConfigured, nil) } // Validate cert type using canonical constants if certType != pkggrpc.CertTypeCA && certType != pkggrpc.CertTypeClient && certType != pkggrpc.CertTypeKey { s.logger.ErrorContext(ctx, pkggrpc.LogDownloadCertFailed, "cert_type", certType, "error", pkggrpc.ErrTokenCertTypeRequired) - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenCertTypeRequired) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertTypeRequired, nil) } bs, err := s.bsRepo.GetByEUI(ctx, tenantID, bsEui) if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertBSNotFound, "bs_eui", hex.EncodeToString(bsEui), "error", err) - return nil, "", fmt.Errorf("%s: %w", pkggrpc.ErrTokenBaseStationNotFound, err) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenBaseStationNotFound, err) } euiHex := hex.EncodeToString(bsEui) @@ -627,28 +633,28 @@ func (s *Service) GetStoredCertificate(ctx context.Context, tenantID int64, bsEu switch certType { case pkggrpc.CertTypeCA: if bs.TLSCACertificate == nil || *bs.TLSCACertificate == "" { - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenCertNotFound) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertNotFound, nil) } data = []byte(*bs.TLSCACertificate) filename = fmt.Sprintf("basestation-%s-ca-certificate.crt", euiHex) case pkggrpc.CertTypeClient: if bs.TLSCertificate == nil || *bs.TLSCertificate == "" { - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenCertNotFound) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertNotFound, nil) } data = []byte(*bs.TLSCertificate) filename = fmt.Sprintf("basestation-%s-client-certificate.crt", euiHex) case pkggrpc.CertTypeKey: if bs.TLSKey == nil || *bs.TLSKey == "" { - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenCertNotFound) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertNotFound, nil) } if s.keyEncryptor == nil { s.logger.ErrorContext(ctx, pkggrpc.LogDownloadCertFailed, "bs_eui", euiHex, "error", pkggrpc.ErrTokenServiceNotConfigured) - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenServiceNotConfigured) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenServiceNotConfigured, nil) } decrypted, decryptErr := s.keyEncryptor.DecryptKey(*bs.TLSKey) if decryptErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogDownloadCertFailed, "bs_eui", euiHex, "error", decryptErr) - return nil, "", fmt.Errorf("%s", pkggrpc.ErrTokenCertNotFound) + return nil, "", pkggrpc.NewTokenError(pkggrpc.ErrTokenCertNotFound, nil) } data = decrypted filename = fmt.Sprintf("basestation-%s-private-key.key", euiHex) @@ -662,44 +668,44 @@ func (s *Service) persistCertsToBaseStation(ctx context.Context, certDir string, // First lookup the base station to get its ID (Update requires int64 ID, not EUI) bs, err := s.bsRepo.GetByEUI(ctx, tenantID, bsEui) if err != nil { - return fmt.Errorf("%s", pkggrpc.ErrTokenBaseStationNotFound) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenBaseStationNotFound, nil) } // Read certificate files with proper error handling caCertData, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) // #nosec G304 - path from UUID-based certDir if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertFileReadFailed, "file", "ca.crt", "error", err) - return fmt.Errorf("%s", pkggrpc.ErrTokenCACertReadFailed) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCACertReadFailed, nil) } clientCertData, err := os.ReadFile(filepath.Join(certDir, "client.crt")) // #nosec G304 - path from UUID-based certDir if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertFileReadFailed, "file", "client.crt", "error", err) - return fmt.Errorf("%s", pkggrpc.ErrTokenCertGenerationFailed) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertGenerationFailed, nil) } clientKeyData, err := os.ReadFile(filepath.Join(certDir, "client.key")) // #nosec G304 - path from UUID-based certDir if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertFileReadFailed, "file", "client.key", "error", err) - return fmt.Errorf("%s", pkggrpc.ErrTokenCertGenerationFailed) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertGenerationFailed, nil) } // Parse certificate and compute fingerprint for audit/identity block, _ := pem.Decode(clientCertData) if block == nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertPEMBlockParseFailed, "file", "client.crt") - return fmt.Errorf("%s", pkggrpc.ErrTokenCertGenerationFailed) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertGenerationFailed, nil) } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertParseFailed, "file", "client.crt", "error", err) - return fmt.Errorf("%s", pkggrpc.ErrTokenCertGenerationFailed) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertGenerationFailed, nil) } - fingerprint := fmt.Sprintf("%x", sha256.Sum256(cert.Raw)) + fingerprint := crypto.CertFingerprintSHA256(cert.Raw) // Encrypt private key (required for storage) encrypted, encErr := s.keyEncryptor.EncryptKey(clientKeyData) if encErr != nil { s.logger.ErrorContext(ctx, pkggrpc.LogCertGenerationFailed, "operation", "encrypt", "error", encErr) - return fmt.Errorf("%s", pkggrpc.ErrTokenCertGenerationFailed) + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCertGenerationFailed, nil) } updates := map[string]interface{}{ diff --git a/KC-Core/internal/services/certificates/service_test.go b/KC-Core/internal/services/certificates/service_test.go index 6d9bcb0..dca6e7d 100644 --- a/KC-Core/internal/services/certificates/service_test.go +++ b/KC-Core/internal/services/certificates/service_test.go @@ -2,8 +2,16 @@ package certificates import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" "encoding/json" + "encoding/pem" "errors" + "math/big" "os" "path/filepath" "strings" @@ -12,6 +20,7 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/internal/services/grpcservices" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/config" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/crypto" pkggrpc "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" @@ -34,6 +43,21 @@ func (m *mockLogger) FatalContext(_ context.Context, _ string, _ ...interface{}) func (m *mockLogger) WithField(_ string, _ interface{}) logger.Logger { return m } func (m *mockLogger) WithFields(_ map[string]interface{}) logger.Logger { return m } +type mockKeyEncryptor struct { + encryptErr error +} + +func (m *mockKeyEncryptor) EncryptKey(key []byte) (string, error) { + if m.encryptErr != nil { + return "", m.encryptErr + } + return base64.StdEncoding.EncodeToString(key), nil +} + +func (m *mockKeyEncryptor) DecryptKey(encrypted string) ([]byte, error) { + return base64.StdEncoding.DecodeString(encrypted) +} + func TestDownloadCertificateByID_ShortCertID(t *testing.T) { tempDir := t.TempDir() cfg := &config.Config{ @@ -42,7 +66,10 @@ func TestDownloadCertificateByID_ShortCertID(t *testing.T) { }, } - svc := New(cfg, &mockLogger{}) + svc, err := New(cfg, &mockLogger{}, &mockBaseStationRepo{}, &mockKeyEncryptor{}, nil) + if err != nil { + t.Fatalf("New: %v", err) + } shortCertID := "abc123" certDir := filepath.Join(tempDir, shortCertID) @@ -79,6 +106,11 @@ func TestDownloadCertificateByID_ShortCertID(t *testing.T) { func newGenerateTestService(t *testing.T) *Service { t.Helper() tmpDir := t.TempDir() + for _, name := range []string{"ca.crt", "ca.key"} { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("staged"), 0600); err != nil { + t.Fatalf("stage %s: %v", name, err) + } + } return &Service{ config: &config.Config{}, logger: &mockLogger{}, @@ -87,6 +119,9 @@ func newGenerateTestService(t *testing.T) *Service { tempDir: filepath.Join(tmpDir, "temp"), serverValidityDays: config.DefaultCertificatesServerValidityDays, protocolConfig: &config.ProtocolConfig{}, + bsRepo: &mockBaseStationRepo{bs: &models.BaseStation{ID: 1, TenantID: 42}}, + keyEncryptor: &mockKeyEncryptor{}, + certGen: execCertGen, } } @@ -113,6 +148,7 @@ func TestGenerateCertificate_AcceptsDashedHighBitEUI(t *testing.T) { _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ BsEUI: "CA-FE-CA-FE-CA-FE-CA-FE", ValidityDays: 365, + TenantID: 42, }) if err == nil { t.Fatal("expected generator-not-found error, got nil") @@ -133,6 +169,7 @@ func TestGenerateCertificate_AcceptsPlainHexEUI(t *testing.T) { _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ BsEUI: "cafecafecafecafe", ValidityDays: 365, + TenantID: 42, }) if err == nil { t.Fatal("expected generator-not-found error, got nil") @@ -157,8 +194,8 @@ func TestGetStoredCertificate_InvalidCertType(t *testing.T) { if err == nil { t.Fatal("expected error") } - if err.Error() != pkggrpc.ErrTokenCertTypeRequired { - t.Fatalf("error = %q, want %q", err.Error(), pkggrpc.ErrTokenCertTypeRequired) + if tok, ok := pkggrpc.TokenOf(err); !ok || tok != pkggrpc.ErrTokenCertTypeRequired { + t.Fatalf("error = %q, want typed token %q", err.Error(), pkggrpc.ErrTokenCertTypeRequired) } } @@ -179,8 +216,8 @@ func TestGetStoredCertificate_KeyEncryptorRequired(t *testing.T) { if err == nil { t.Fatal("expected error") } - if err.Error() != pkggrpc.ErrTokenServiceNotConfigured { - t.Fatalf("error = %q, want %q", err.Error(), pkggrpc.ErrTokenServiceNotConfigured) + if tok, ok := pkggrpc.TokenOf(err); !ok || tok != pkggrpc.ErrTokenServiceNotConfigured { + t.Fatalf("error = %q, want typed token %q", err.Error(), pkggrpc.ErrTokenServiceNotConfigured) } } @@ -200,8 +237,8 @@ func TestPersistCertsToBaseStation_MissingCACert(t *testing.T) { if err == nil { t.Fatal("expected error") } - if err.Error() != pkggrpc.ErrTokenCACertReadFailed { - t.Fatalf("error = %q, want %q", err.Error(), pkggrpc.ErrTokenCACertReadFailed) + if tok, ok := pkggrpc.TokenOf(err); !ok || tok != pkggrpc.ErrTokenCACertReadFailed { + t.Fatalf("error = %q, want typed token %q", err.Error(), pkggrpc.ErrTokenCACertReadFailed) } } @@ -352,7 +389,10 @@ func TestNew_ServerValidityDaysFromConfig(t *testing.T) { }, } - svc := New(cfg, &mockLogger{}) + svc, err := New(cfg, &mockLogger{}, &mockBaseStationRepo{}, &mockKeyEncryptor{}, nil) + if err != nil { + t.Fatalf("New: %v", err) + } if svc.serverValidityDays != 730 { t.Errorf("expected serverValidityDays=730, got %d", svc.serverValidityDays) @@ -427,7 +467,10 @@ func TestNew_ServerValidityDaysFallsBackToDefault(t *testing.T) { }, } - svc := New(cfg, &mockLogger{}) + svc, err := New(cfg, &mockLogger{}, &mockBaseStationRepo{}, &mockKeyEncryptor{}, nil) + if err != nil { + t.Fatalf("New: %v", err) + } if svc.serverValidityDays != config.DefaultCertificatesServerValidityDays { t.Errorf("expected serverValidityDays=%d (default), got %d", @@ -440,6 +483,11 @@ func TestNew_ServerValidityDaysFallsBackToDefault(t *testing.T) { func newGenerateTestServiceWithRepo(t *testing.T, repo *mockBaseStationRepo) *Service { t.Helper() tmpDir := t.TempDir() + for _, name := range []string{"ca.crt", "ca.key"} { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("staged"), 0600); err != nil { + t.Fatalf("stage %s: %v", name, err) + } + } return &Service{ config: &config.Config{}, logger: &mockLogger{}, @@ -449,6 +497,8 @@ func newGenerateTestServiceWithRepo(t *testing.T, repo *mockBaseStationRepo) *Se serverValidityDays: config.DefaultCertificatesServerValidityDays, protocolConfig: &config.ProtocolConfig{}, bsRepo: repo, + keyEncryptor: &mockKeyEncryptor{}, + certGen: execCertGen, } } @@ -498,3 +548,167 @@ func TestGenerateCertificate_OwnedProceedsToGeneration(t *testing.T) { t.Errorf("error = %q, want it to reach the certgen step", err.Error()) } } + +func (m *mockBaseStationRepo) UpdateTLSFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + return true, nil +} + +// fakeCertGen is a CertGenRunner producing real PEM material without the +// external certgen binary. +func fakeCertGen(t *testing.T) CertGenRunner { + t.Helper() + return func(_ context.Context, _ string, args ...string) (string, string, error) { + var dir string + for i := 0; i < len(args)-1; i++ { + if args[i] == "-dir" { + dir = args[i+1] + } + } + if dir == "" { + return "", "no -dir argument", errors.New("missing -dir") + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", err.Error(), err + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "CA-FE-CA-FE-CA-FE-CA-FE"}, + NotAfter: time.Now().Add(24 * time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return "", err.Error(), err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return "", err.Error(), err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + for name, data := range map[string][]byte{ + "ca.crt": certPEM, + "client.crt": certPEM, + "client.key": keyPEM, + } { + if err := os.WriteFile(filepath.Join(dir, name), data, 0600); err != nil { + return "", err.Error(), err + } + } + return "generated", "", nil + } +} + +// newIssuanceTestService builds a fully wired service whose generator emits +// real PEM, with CA material staged in certsDir for the copy step. +func newIssuanceTestService(t *testing.T, repo *mockBaseStationRepo) *Service { + t.Helper() + tmpDir := t.TempDir() + // Stage CA files the issuance path copies into the working dir + for _, name := range []string{"ca.crt", "ca.key"} { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("staged"), 0600); err != nil { + t.Fatalf("stage %s: %v", name, err) + } + } + return &Service{ + config: &config.Config{}, + logger: &mockLogger{}, + certGenPath: filepath.Join(tmpDir, "certgen-unused"), + certsDir: tmpDir, + tempDir: filepath.Join(tmpDir, "temp"), + serverValidityDays: config.DefaultCertificatesServerValidityDays, + protocolConfig: &config.ProtocolConfig{}, + bsRepo: repo, + keyEncryptor: &mockKeyEncryptor{}, + certGen: fakeCertGen(t), + } +} + +// TestGenerateCertificate_PersistsMatchingFingerprint: real issuance persists +// the certificate with a fingerprint equal to the canonical SHA-256 of the +// generated certificate. +func TestGenerateCertificate_PersistsMatchingFingerprint(t *testing.T) { + repo := &mockBaseStationRepo{bs: &models.BaseStation{ID: 1, TenantID: 42}} + svc := newIssuanceTestService(t, repo) + ctx := testutil.TestContext() + + resp, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "cafecafecafecafe", + ValidityDays: 365, + TenantID: 42, + }) + if err != nil { + t.Fatalf("issuance failed: %v", err) + } + if resp == nil { + t.Fatal("expected a certificate response") + } + + stored, ok := repo.updateArgs["tls_cert_fingerprint"].(string) + if !ok || stored == "" { + t.Fatalf("fingerprint not persisted: %v", repo.updateArgs) + } + certPEM, ok := repo.updateArgs["tls_certificate"].(string) + if !ok { + t.Fatal("certificate not persisted") + } + derived, err := crypto.CertFingerprintFromPEM([]byte(certPEM)) + if err != nil { + t.Fatalf("derive fingerprint: %v", err) + } + if stored != derived { + t.Fatalf("stored fingerprint %q != derived %q", stored, derived) + } + if stored != strings.ToLower(stored) { + t.Fatalf("fingerprint must be lowercase hex, got %q", stored) + } +} + +// TestGenerateCertificate_PersistenceFailureReturnsNoCert: a persistence +// failure surfaces as the typed persistence token and no certificate is +// returned (the station must never hold a certificate the service center did +// not record). +func TestGenerateCertificate_PersistenceFailureReturnsNoCert(t *testing.T) { + repo := &mockBaseStationRepo{ + bs: &models.BaseStation{ID: 1, TenantID: 42}, + updateErr: errors.New("db down"), + } + svc := newIssuanceTestService(t, repo) + ctx := testutil.TestContext() + + resp, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "cafecafecafecafe", + ValidityDays: 365, + TenantID: 42, + }) + if err == nil { + t.Fatal("expected persistence failure") + } + if resp != nil { + t.Fatal("no certificate may be returned when persistence failed") + } + if tok, ok := pkggrpc.TokenOf(err); !ok || tok != pkggrpc.ErrTokenCertPersistenceFailed { + t.Fatalf("error = %q, want typed token %q", err.Error(), pkggrpc.ErrTokenCertPersistenceFailed) + } +} + +// TestGenerateCertificate_TenantZeroFailsClosed: issuance without a tenant is +// rejected before any generation work. +func TestGenerateCertificate_TenantZeroFailsClosed(t *testing.T) { + repo := &mockBaseStationRepo{bs: &models.BaseStation{ID: 1, TenantID: 42}} + svc := newIssuanceTestService(t, repo) + ctx := testutil.TestContext() + + _, err := svc.GenerateCertificate(ctx, &grpcservices.CertificateRequest{ + BsEUI: "cafecafecafecafe", + ValidityDays: 365, + }) + if err == nil { + t.Fatal("expected tenant-zero rejection") + } + if tok, ok := pkggrpc.TokenOf(err); !ok || tok != pkggrpc.ErrTokenMissingTenantCtx { + t.Fatalf("error = %q, want typed token %q", err.Error(), pkggrpc.ErrTokenMissingTenantCtx) + } +} diff --git a/KC-Core/pkg/bssci/cert_enforcement_test.go b/KC-Core/pkg/bssci/cert_enforcement_test.go new file mode 100644 index 0000000..e0ba02d --- /dev/null +++ b/KC-Core/pkg/bssci/cert_enforcement_test.go @@ -0,0 +1,231 @@ +package bssci + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "testing" + + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/crypto" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeEnforcementCert creates a real self-signed certificate and its PEM. +func makeEnforcementCert(t *testing.T, cn string) (*x509.Certificate, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + pemData := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + return cert, string(pemData) +} + +// fakeBSDirectory implements RegisteredBaseStationDirectory for enforcement tests. +type fakeBSDirectory struct { + station RegisteredBaseStation + getErr error + backfillResult bool + backfillErr error + backfillCalls int + // reloadStation is returned by the GetGlobal call after a lost backfill race + reloadStation *RegisteredBaseStation + getCalls int +} + +func (d *fakeBSDirectory) GetGlobal(_ context.Context, _ uint64) (RegisteredBaseStation, error) { + d.getCalls++ + if d.getErr != nil { + return RegisteredBaseStation{}, d.getErr + } + if d.getCalls > 1 && d.reloadStation != nil { + return *d.reloadStation, nil + } + return d.station, nil +} + +func (d *fakeBSDirectory) BackfillFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + d.backfillCalls++ + return d.backfillResult, d.backfillErr +} + +func newEnforcementServer(t *testing.T, directory *fakeBSDirectory) (*Server, *Session, *x509.Certificate, string) { + t.Helper() + log := logger.NewNop() + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, storage := CreateTestServices(log, nil) + server := NewTestServer(log, storage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, nil, broadcaster, + queueSerializer, auditLogger, tenantResolver) + server.bsDirectory = directory + + cert, pemData := makeEnforcementCert(t, "CA-FE-CA-FE-CA-FE-CA-FE") + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "cert-enforcement", + BaseStationEUI: 0xCAFECAFECAFECAFE, + }, + ClientCert: cert, + } + return server, session, cert, pemData +} + +// TestVerifyCertificateFingerprint_Match: the presented certificate matching +// the stored fingerprint passes. +func TestVerifyCertificateFingerprint_Match(t *testing.T) { + directory := &fakeBSDirectory{} + server, session, cert, _ := newEnforcementServer(t, directory) + directory.station = RegisteredBaseStation{ + ID: 7, TenantID: 42, + TLSCertFingerprint: crypto.CertFingerprintSHA256(cert.Raw), + } + + assert.NoError(t, server.verifyCertificateFingerprint(context.Background(), session)) +} + +// TestVerifyCertificateFingerprint_ForgedSameCNRejected: a different +// certificate with the same CN is rejected by the fingerprint comparison. +func TestVerifyCertificateFingerprint_ForgedSameCNRejected(t *testing.T) { + directory := &fakeBSDirectory{} + server, session, _, _ := newEnforcementServer(t, directory) + otherCert, _ := makeEnforcementCert(t, "CA-FE-CA-FE-CA-FE-CA-FE") + directory.station = RegisteredBaseStation{ + ID: 7, TenantID: 42, + TLSCertFingerprint: crypto.CertFingerprintSHA256(otherCert.Raw), + } + + assert.Error(t, server.verifyCertificateFingerprint(context.Background(), session), + "a forged certificate sharing the CN must be rejected") +} + +// TestVerifyCertificateFingerprint_BlankBackfillsFromStoredPEM: a pre-upgrade +// row (blank fingerprint, stored PEM of the same certificate) is compared +// against the presented certificate first and then backfilled. +func TestVerifyCertificateFingerprint_BlankBackfillsFromStoredPEM(t *testing.T) { + directory := &fakeBSDirectory{backfillResult: true} + server, session, _, pemData := newEnforcementServer(t, directory) + directory.station = RegisteredBaseStation{ + ID: 7, TenantID: 42, + TLSCertificate: pemData, + } + + require.NoError(t, server.verifyCertificateFingerprint(context.Background(), session)) + assert.Equal(t, 1, directory.backfillCalls, "the derived fingerprint is persisted") +} + +// TestVerifyCertificateFingerprint_BlankStoredPEMOtherCertRejected: a blank +// fingerprint with a stored PEM of a DIFFERENT certificate rejects the +// connection and never backfills. +func TestVerifyCertificateFingerprint_BlankStoredPEMOtherCertRejected(t *testing.T) { + directory := &fakeBSDirectory{backfillResult: true} + server, session, _, _ := newEnforcementServer(t, directory) + _, otherPEM := makeEnforcementCert(t, "CA-FE-CA-FE-CA-FE-CA-FE") + directory.station = RegisteredBaseStation{ + ID: 7, TenantID: 42, + TLSCertificate: otherPEM, + } + + require.Error(t, server.verifyCertificateFingerprint(context.Background(), session)) + assert.Zero(t, directory.backfillCalls, "a mismatch must never be persisted") +} + +// TestVerifyCertificateFingerprint_BlankNoStoredCertRejected: a blank +// fingerprint with no stored certificate has no verifiable identity. +func TestVerifyCertificateFingerprint_BlankNoStoredCertRejected(t *testing.T) { + directory := &fakeBSDirectory{} + server, session, _, _ := newEnforcementServer(t, directory) + directory.station = RegisteredBaseStation{ID: 7, TenantID: 42} + + assert.Error(t, server.verifyCertificateFingerprint(context.Background(), session)) +} + +// TestVerifyCertificateFingerprint_BackfillRaceReloadsAndCompares: a lost +// backfill race (zero rows updated) reloads the row and compares against the +// concurrently written fingerprint. +func TestVerifyCertificateFingerprint_BackfillRaceReloadsAndCompares(t *testing.T) { + directory := &fakeBSDirectory{backfillResult: false} + server, session, cert, pemData := newEnforcementServer(t, directory) + directory.station = RegisteredBaseStation{ + ID: 7, TenantID: 42, + TLSCertificate: pemData, + } + directory.reloadStation = &RegisteredBaseStation{ + ID: 7, TenantID: 42, + TLSCertFingerprint: crypto.CertFingerprintSHA256(cert.Raw), + } + + require.NoError(t, server.verifyCertificateFingerprint(context.Background(), session)) + assert.Equal(t, 2, directory.getCalls, "the lost race reloads the row") + + // A concurrent writer that stored a DIFFERENT fingerprint rejects + directory2 := &fakeBSDirectory{backfillResult: false} + server2, session2, _, pemData2 := newEnforcementServer(t, directory2) + directory2.station = RegisteredBaseStation{ID: 7, TenantID: 42, TLSCertificate: pemData2} + directory2.reloadStation = &RegisteredBaseStation{ID: 7, TenantID: 42, TLSCertFingerprint: "deadbeef"} + assert.Error(t, server2.verifyCertificateFingerprint(context.Background(), session2)) +} + +// TestVerifyCertificateFingerprint_LookupFailureRejects: an unreadable +// registration rejects rather than skipping enforcement. +func TestVerifyCertificateFingerprint_LookupFailureRejects(t *testing.T) { + directory := &fakeBSDirectory{getErr: errors.New("db down")} + server, session, _, _ := newEnforcementServer(t, directory) + + assert.Error(t, server.verifyCertificateFingerprint(context.Background(), session)) +} + +// TestConnectStrictMode_CertSubjectEUIMismatchRejected: in strict mode an +// EUI-CN certificate bound to a different station than the connect bsEui is +// rejected indistinguishably from an unregistered station. +func TestConnectStrictMode_CertSubjectEUIMismatchRejected(t *testing.T) { + log := logger.NewNop() + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, storage := CreateTestServices(log, nil) + server := NewTestServer(log, storage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, interopConnectionService{}, broadcaster, + queueSerializer, auditLogger, tenantResolver) + server.config = &Config{ + MessageEncoding: EncodingJSON, + OrgEnforcementEnabled: true, + ServiceCenterEUI: TestBsEui02, + Vendor: "v", Model: "m", Name: "n", SoftwareVersion: "1.0.0", + } + server.RegisterHandlers() + + certEUI := uint64(0x1111111111111111) // bound to a DIFFERENT station + conn := &bsscitest.TestConn{Encoding: EncodingJSON} + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "strict-eui-mismatch", + Encoding: EncodingJSON, + ResolvedTenantID: 1, // matches the registered tenant + ConnectState: ConnectStateAwaitingConnect, + }, + Conn: conn, + certSubjectEUI: &certEUI, + } + + payload := connectPayload("1.0.0", uint64(TestBsEui01)) + msg := &Message{Command: payload["command"].(string), OpId: 0, Data: payload} + + require.NoError(t, server.CallHandleConnect(session, msg, payload)) + + require.GreaterOrEqual(t, conn.MessageCount(), 1) + errFrame := conn.GetMessage(conn.MessageCount() - 1) + assert.Equal(t, "error", errFrame["command"], + "an EUI-CN certificate bound to another station must be rejected") + assert.Equal(t, ConnectStateAwaitingConnectErrorAck, session.ConnectState) +} diff --git a/KC-Core/pkg/bssci/connect_handler_test.go b/KC-Core/pkg/bssci/connect_handler_test.go index fb9a14c..30329b2 100644 --- a/KC-Core/pkg/bssci/connect_handler_test.go +++ b/KC-Core/pkg/bssci/connect_handler_test.go @@ -631,3 +631,7 @@ func TestConnectHandler_OutOfRangeGeoLocation_NoPersistence(t *testing.T) { assert.NotContains(t, trackingRepo.updatesMap, "location_source", "location_source should not be set for out-of-range") assert.NotContains(t, trackingRepo.updatesMap, "location_updated_at", "location_updated_at should not be set for out-of-range") } + +func (m *geoLocationTrackingRepo) UpdateTLSFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + return true, nil +} diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index edf4c9a..db0799b 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -2,6 +2,7 @@ package bssci import ( "context" + "crypto/x509" "encoding/json" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" @@ -167,6 +168,51 @@ type ConnectionService interface { RegisterConnection(ctx context.Context, session *Session, baseStation *basestation.BaseStation, mgr *basestation.ConnectionManager) error } +// CertificateIdentity is the tenant/organization identity resolved from a +// base station's TLS client certificate. SubjectEUI is set only when the +// certificate CN encodes a base station EUI-64 (the CE issuance scheme); +// legacy org- CNs carry no station identity. +type CertificateIdentity struct { + OrganizationID uuid.UUID + TenantID int64 + SubjectEUI *uint64 +} + +// CertificateIdentityResolver resolves the identity asserted by a TLS client +// certificate at connection accept. The CE composite implementation resolves +// dashed-EUI CNs against the registered base stations and delegates other CN +// forms (org-) to the deployment's organization resolver. +type CertificateIdentityResolver interface { + ResolveCertificateIdentity(ctx context.Context, cert *x509.Certificate) (CertificateIdentity, error) +} + +// RegisteredBaseStation is the persistent registration identity of a base +// station as the BSSCI connect path consumes it. It deliberately carries no +// organization ID - organization context is resolved separately - and no +// live-connection state, which belongs to the connection registry. +type RegisteredBaseStation struct { + ID int64 + TenantID int64 + EUI uint64 + Name string + TLSCertificate string + TLSCertFingerprint string +} + +// RegisteredBaseStationDirectory reads registered station identity for +// certificate enforcement during connect and backfills the certificate +// fingerprint for rows issued before fingerprints were stored. +type RegisteredBaseStationDirectory interface { + // GetGlobal returns the registration for an EUI across all tenants + // (tenant is not yet authenticated at TLS accept). + GetGlobal(ctx context.Context, eui uint64) (RegisteredBaseStation, error) + + // BackfillFingerprintIfBlank persists the fingerprint only while the + // stored value is still blank; reports whether a row was updated (false + // signals a concurrent writer - reload and compare). + BackfillFingerprintIfBlank(ctx context.Context, tenantID, id int64, fingerprint string) (bool, error) +} + // SCACIBroadcaster forwards via real scaciBroadcaster interface // Matches the exact signatures from scaciBroadcaster to enable proper delegation type SCACIBroadcaster interface { diff --git a/KC-Core/pkg/bssci/log_messages.go b/KC-Core/pkg/bssci/log_messages.go index e5eefb5..39ead2d 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -117,6 +117,16 @@ const ( LogBSSCIReceivedErrorAckFromBaseStation = "Received errorAck from base station" // LogBSSCIFailedToResolveOwnerOrgForDLRxQuery is a log message constant LogBSSCIFailedToResolveOwnerOrgForDLRxQuery = "Failed to resolve endpoint owner org for DL RX query tracking" + // LogBSSCICertIdentityDefaultOrgLookupFailed is a log message constant + LogBSSCICertIdentityDefaultOrgLookupFailed = "Failed to resolve default organization for certificate identity" + // LogBSSCICertIdentityRejectedStrictMode is a log message constant + LogBSSCICertIdentityRejectedStrictMode = "Certificate identity resolution failed; closing connection (org enforcement enabled)" + // LogBSSCICertFingerprintMismatch is a log message constant + LogBSSCICertFingerprintMismatch = "Presented certificate does not match the registered fingerprint" + // LogBSSCICertFingerprintBackfilled is a log message constant + LogBSSCICertFingerprintBackfilled = "Backfilled certificate fingerprint from stored PEM" + // LogBSSCICertSubjectEUIMismatch is a log message constant + LogBSSCICertSubjectEUIMismatch = "Certificate subject EUI does not match the connect bsEui" // LogBSSCIUnsolicitedErrorAck is a log message constant LogBSSCIUnsolicitedErrorAck = "Ignoring errorAck with no matching sent error" // LogBSSCIClosingConnectionAfterWriteFailure is a log message constant diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index 7edc33a..327a1f3 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -126,8 +126,13 @@ type Server struct { tenantResolver TenantResolver // Queue-to-tenant mapping (replaces queueTenants map) // Organization resolution - orgResolver org.Resolver // Organization UUID → tenant ID resolution - defaultTenantID int64 // Community mode fallback tenant ID + orgResolver org.Resolver // Organization UUID → tenant ID resolution + // Certificate identity enforcement (strict mode): resolver maps the TLS + // client certificate to a tenant/org identity at accept; the directory + // reads registered station identity for connect-time enforcement + certIdentityResolver CertificateIdentityResolver + bsDirectory RegisteredBaseStationDirectory + defaultTenantID int64 // Community mode fallback tenant ID // Keep essential infrastructure deduplicator *MessageDeduplicator @@ -334,6 +339,10 @@ type Session struct { // and never survives resume; connect handshake errors (opId 0) are tracked // by ConnectState instead. Guarded by the ProtocolSessionState mutex. pendingErrorAcks map[int64]errorAckDisposition + // certSubjectEUI is the base station EUI encoded in the TLS client + // certificate CN (CE issuance scheme), enforced against the connect + // bsEui in strict mode; nil for org- certificates. + certSubjectEUI *uint64 // resumePendingOps is the strictly decoded pending-operation snapshot // loaded during a compatible resume, held on the provisional connection // until conCmp activation restores the cache and reissues the eligible @@ -839,6 +848,93 @@ func (s *Server) SetDetachValidator(validator DetachSignatureValidator) { s.detachValidator = validator } +// SetCertificateIdentityResolver wires certificate-identity resolution for +// TLS accept (CE composite resolver; ECE inherits it through the builders). +func (s *Server) SetCertificateIdentityResolver(resolver CertificateIdentityResolver) { + s.certIdentityResolver = resolver +} + +// SetBaseStationDirectory wires the registered-station directory used for +// connect-time certificate enforcement. +func (s *Server) SetBaseStationDirectory(directory RegisteredBaseStationDirectory) { + s.bsDirectory = directory +} + +// defaultOrgForSessionTenant resolves the default organization for the +// session's current tenant (community fallback path); uuid.Nil when +// unresolvable. +func (s *Server) defaultOrgForSessionTenant(ctx context.Context, session *Session) uuid.UUID { + if s.orgResolver == nil { + return uuid.Nil + } + orgID, err := s.orgResolver.GetDefaultOrgForTenant(ctx, session.ResolvedTenantID) + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToResolveDefaultOrgForBSSCISession, + "error", err, + "tenantID", session.ResolvedTenantID) + return uuid.Nil + } + return orgID +} + +// verifyCertificateFingerprint enforces the stored-certificate binding in +// strict mode: the presented client certificate's SHA-256 fingerprint must +// equal the registered station's stored fingerprint. Rows issued before +// fingerprints were stored are backfilled from the stored PEM after the +// presented certificate matched it (upgrade path); blank fingerprint with no +// stored certificate is rejected. +func (s *Server) verifyCertificateFingerprint(ctx context.Context, session *Session) error { + registered, err := s.bsDirectory.GetGlobal(ctx, session.BaseStationEUI) + if err != nil { + return fmt.Errorf("registered station lookup: %w", err) + } + + presented := crypto.CertFingerprintSHA256(session.ClientCert.Raw) + + stored := registered.TLSCertFingerprint + if stored == "" { + if registered.TLSCertificate == "" { + return fmt.Errorf("station %016X has no stored certificate identity", session.BaseStationEUI) + } + derived, deriveErr := crypto.CertFingerprintFromPEM([]byte(registered.TLSCertificate)) + if deriveErr != nil { + return fmt.Errorf("station %016X stored certificate unparsable: %w", session.BaseStationEUI, deriveErr) + } + if derived != presented { + s.logger.WarnContext(ctx, LogBSSCICertFingerprintMismatch, + "bsEui", session.BaseStationEUI) + return fmt.Errorf("station %016X presented certificate does not match stored certificate", session.BaseStationEUI) + } + updated, backfillErr := s.bsDirectory.BackfillFingerprintIfBlank(ctx, registered.TenantID, registered.ID, derived) + if backfillErr != nil { + return fmt.Errorf("station %016X fingerprint backfill: %w", session.BaseStationEUI, backfillErr) + } + if !updated { + // A concurrent writer set the fingerprint first: reload and compare + reloaded, reloadErr := s.bsDirectory.GetGlobal(ctx, session.BaseStationEUI) + if reloadErr != nil { + return fmt.Errorf("registered station reload: %w", reloadErr) + } + if reloaded.TLSCertFingerprint != presented { + s.logger.WarnContext(ctx, LogBSSCICertFingerprintMismatch, + "bsEui", session.BaseStationEUI) + return fmt.Errorf("station %016X presented certificate does not match registered fingerprint", session.BaseStationEUI) + } + return nil + } + s.logger.InfoContext(ctx, LogBSSCICertFingerprintBackfilled, + "bsEui", session.BaseStationEUI) + return nil + } + + if stored != presented { + s.logger.WarnContext(ctx, LogBSSCICertFingerprintMismatch, + "bsEui", session.BaseStationEUI) + return fmt.Errorf("station %016X presented certificate does not match registered fingerprint", session.BaseStationEUI) + } + return nil +} + // SetDownlinkDispatcher wires the auto-dispatch service (BSSCI §5.10.2) // Called after service construction to enable dlOpen=true automatic dispatch. func (s *Server) SetDownlinkDispatcher(dispatcher DownlinkDispatcher) { @@ -1126,26 +1222,51 @@ func (s *Server) handleConnection(conn net.Conn) { var tenantID int64 var err error - if s.orgResolver != nil { + switch { + case s.certIdentityResolver != nil: + identity, resolveErr := s.certIdentityResolver.ResolveCertificateIdentity(ctx, cert) + if resolveErr != nil { + // Strict mode: an unresolvable certificate closes the + // connection before any con is read - no default-tenant + // fallback (the fallback would let an unknown certificate + // operate under the server's default tenant) + if s.config != nil && s.config.OrgEnforcementEnabled { + s.logger.ErrorContext(ctx, LogBSSCICertIdentityRejectedStrictMode, + "error", resolveErr, + "certCN", cert.Subject.CommonName) + return + } + // Community fallback: default tenant + its default org + s.logger.WarnContext(ctx, LogBSSCICertOrgResolutionFailedUsingCommunityFallback, + "error", resolveErr, + "certCN", cert.Subject.CommonName, + "tenantID", session.ResolvedTenantID) + orgID = s.defaultOrgForSessionTenant(ctx, session) + } else { + session.ResolvedTenantID = identity.TenantID + session.certSubjectEUI = identity.SubjectEUI + orgID = identity.OrganizationID + s.logger.InfoContext(ctx, LogBSSCICertOrgResolutionSucceeded, + "orgID", orgID.String(), + "tenantID", identity.TenantID, + "certCN", cert.Subject.CommonName) + } + case s.orgResolver != nil: orgID, tenantID, err = s.orgResolver.ResolveCert(ctx, cert) if err != nil { - // Certificate-based resolution failed - use community fallback + // Certificate-based resolution failed + if s.config != nil && s.config.OrgEnforcementEnabled { + s.logger.ErrorContext(ctx, LogBSSCICertIdentityRejectedStrictMode, + "error", err, + "certCN", cert.Subject.CommonName) + return + } + // Community fallback: default tenant + its default org s.logger.WarnContext(ctx, LogBSSCICertOrgResolutionFailedUsingCommunityFallback, "error", err, "certCN", cert.Subject.CommonName, "tenantID", session.ResolvedTenantID) - - // Get default org for the server's default tenant - orgID, err = s.orgResolver.GetDefaultOrgForTenant(ctx, session.ResolvedTenantID) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToResolveDefaultOrgForBSSCISession, - "error", err, - "tenantID", session.ResolvedTenantID) - // Continue with nil UUID - will be NULL in database - orgID = uuid.Nil - // ResolvedTenantID already set to s.defaultTenantID in session init - } - // else: fallback succeeded, ResolvedTenantID still s.defaultTenantID + orgID = s.defaultOrgForSessionTenant(ctx, session) } else { // SUCCESS: cert resolution worked, use resolved tenant session.ResolvedTenantID = tenantID @@ -1154,8 +1275,8 @@ func (s *Server) handleConnection(conn net.Conn) { "tenantID", tenantID, "certCN", cert.Subject.CommonName) } - } else { - // No org resolver - community edition + default: + // No resolver - community edition orgID = uuid.Nil } @@ -1908,6 +2029,29 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m "certTenant", session.ResolvedTenantID) return s.rejectConnect(session, msg.OpId, POSIX_EPERM, errBaseStationNotRegistered) } + + // EUI-CN certificates bind the certificate to one station: the + // asserted EUI must equal the connect bsEui (defense-in-depth beyond + // the spec's mutual-TLS requirement; org- certificates carry no + // station identity and skip this check) + if session.certSubjectEUI != nil && *session.certSubjectEUI != session.BaseStationEUI { + s.logger.WarnContext(ctx, LogBSSCICertSubjectEUIMismatch, + "certEui", *session.certSubjectEUI, + "bsEui", session.BaseStationEUI) + return s.rejectConnect(session, msg.OpId, POSIX_EPERM, errBaseStationNotRegistered) + } + + // The presented certificate must match the registered station's + // stored fingerprint (both CN forms); rejection stays + // indistinguishable from an unregistered station + if session.ClientCert != nil && s.bsDirectory != nil { + if fpErr := s.verifyCertificateFingerprint(ctx, session); fpErr != nil { + s.logger.WarnContext(ctx, LogBSSCICertFingerprintMismatch, + "eui", session.BaseStationEUI, + "error", fpErr) + return s.rejectConnect(session, msg.OpId, POSIX_EPERM, errBaseStationNotRegistered) + } + } } else if baseStation.TenantID > 0 { // Community fallback: adopt the registered base station tenant and // its default organization diff --git a/KC-Core/pkg/bssci/status_handlers_test.go b/KC-Core/pkg/bssci/status_handlers_test.go index f5575ba..045849d 100644 --- a/KC-Core/pkg/bssci/status_handlers_test.go +++ b/KC-Core/pkg/bssci/status_handlers_test.go @@ -2329,3 +2329,19 @@ func TestStatusHandler_PartialTriple_SkipsAll(t *testing.T) { }) } } + +func (m *tenantTrackingBaseStationRepo) UpdateTLSFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + return true, nil +} + +func (m *errorInjectingBaseStationRepo) UpdateTLSFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + return true, nil +} + +func (m *panicOnCallBaseStationRepo) UpdateTLSFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + return true, nil +} + +func (m *concurrentTrackingRepo) UpdateTLSFingerprintIfBlank(_ context.Context, _, _ int64, _ string) (bool, error) { + return true, nil +} diff --git a/KC-Core/pkg/crypto/fingerprint.go b/KC-Core/pkg/crypto/fingerprint.go new file mode 100644 index 0000000..f8132f3 --- /dev/null +++ b/KC-Core/pkg/crypto/fingerprint.go @@ -0,0 +1,32 @@ +package crypto + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" +) + +// CertFingerprintSHA256 returns the lowercase-hex SHA-256 fingerprint of a +// DER-encoded certificate. This is the canonical format stored in the +// tls_cert_fingerprint column and compared during BSSCI connect enforcement. +func CertFingerprintSHA256(der []byte) string { + sum := sha256.Sum256(der) + return hex.EncodeToString(sum[:]) +} + +// CertFingerprintFromPEM parses a PEM-encoded certificate and returns its +// canonical SHA-256 fingerprint. +func CertFingerprintFromPEM(pemData []byte) (string, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return "", errors.New("no PEM block in certificate data") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("parse certificate: %w", err) + } + return CertFingerprintSHA256(cert.Raw), nil +} diff --git a/KC-Core/pkg/grpc/errors_catalog.go b/KC-Core/pkg/grpc/errors_catalog.go index 74b2777..604fea1 100644 --- a/KC-Core/pkg/grpc/errors_catalog.go +++ b/KC-Core/pkg/grpc/errors_catalog.go @@ -2,6 +2,8 @@ package grpc import ( + "errors" + "google.golang.org/grpc/codes" ) @@ -2245,3 +2247,36 @@ func ResolveErrorMessage(token string) string { func GetGRPCCode(token string) codes.Code { return GetErrorDefinition(token).Code } + +// TokenError is a typed catalog error that carries its token across service +// boundaries, so callers match on the token via errors.As instead of +// substring inspection of the error text. +type TokenError struct { + Token string + Err error +} + +// Error renders the token with its resolved catalog message and any cause. +func (e *TokenError) Error() string { + if e.Err != nil { + return e.Token + ": " + e.Err.Error() + } + return e.Token + ": " + ResolveErrorMessage(e.Token) +} + +// Unwrap exposes the underlying cause for errors.Is/As chains. +func (e *TokenError) Unwrap() error { return e.Err } + +// NewTokenError wraps a cause with a catalog token. cause may be nil. +func NewTokenError(token string, cause error) *TokenError { + return &TokenError{Token: token, Err: cause} +} + +// TokenOf extracts the catalog token from an error chain. +func TokenOf(err error) (string, bool) { + var te *TokenError + if errors.As(err, &te) { + return te.Token, true + } + return "", false +} diff --git a/KC-DB/storage/interfaces/basestation_repository.go b/KC-DB/storage/interfaces/basestation_repository.go index 563757b..d5fc578 100644 --- a/KC-DB/storage/interfaces/basestation_repository.go +++ b/KC-DB/storage/interfaces/basestation_repository.go @@ -34,6 +34,12 @@ type BaseStationRepository interface { // UpdateConnectionStatus updates the connection status of a Base Station UpdateConnectionStatus(ctx context.Context, tenantID, id int64, isOnline bool, lastError *string) error + // UpdateTLSFingerprintIfBlank persists the certificate fingerprint only + // while the stored tls_cert_fingerprint is still NULL or empty. Returns + // whether a row was updated; false signals a concurrent writer already + // set it (callers reload and compare). + UpdateTLSFingerprintIfBlank(ctx context.Context, tenantID, id int64, fingerprint string) (bool, error) + // UpdateSessionInfo updates the session information of a Base Station UpdateSessionInfo(ctx context.Context, tenantID int64, eui []byte, sessionUUID string) error diff --git a/KC-DB/storage/postgres/basestation_repository.go b/KC-DB/storage/postgres/basestation_repository.go index c8a9d75..3cac829 100644 --- a/KC-DB/storage/postgres/basestation_repository.go +++ b/KC-DB/storage/postgres/basestation_repository.go @@ -691,3 +691,24 @@ type BaseStationWithStats struct { AvgRSSI float64 `db:"avg_rssi"` AvgSNR float64 `db:"avg_snr"` } + +// UpdateTLSFingerprintIfBlank persists the certificate fingerprint only while +// the stored tls_cert_fingerprint is still NULL or empty (see interface +// contract). The conditional WHERE makes the backfill race-safe: a concurrent +// writer wins and this call reports false so the caller reloads and compares. +func (r *BaseStationRepository) UpdateTLSFingerprintIfBlank(ctx context.Context, tenantID, id int64, fingerprint string) (bool, error) { + result, err := r.db.ExecContext(ctx, ` + UPDATE basestations + SET tls_cert_fingerprint = $1, updated_at = NOW() + WHERE tenant_id = $2 AND id = $3 + AND (tls_cert_fingerprint IS NULL OR tls_cert_fingerprint = '') + `, fingerprint, tenantID, id) + if err != nil { + return false, fmt.Errorf("backfill tls fingerprint: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("backfill tls fingerprint rows: %w", err) + } + return rows > 0, nil +} From 0d4d2ea48e9e7ac9ab525fff6635f7c425c5facf Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:57 +0200 Subject: [PATCH 07/25] refactor(core): repair the SOLID boundary checker and clear its violations CHIRP-3545. The boundary checker was matching nothing for its scoped rules and therefore reporting success regardless of the code. With the checker made meaningful, the real violations it had been hiding are fixed, the dead broadcast hook and normalization test spy are removed, and BSSCI protocol tuning is exposed through configuration instead of scattered literals. Also integrates the preserved legacy archive, purges leaked status polls, and repairs the full-stack probe gates so they exercise the protocol rather than asserting on their own fixtures. --- KC-Core/cmd/kilocenter/builders/protocol.go | 1 + KC-Core/config.ce-test.yaml | 7 +- KC-Core/config/config.yaml | 4 + .../internal/grpc/auth_interceptor_test.go | 24 +- KC-Core/internal/grpc/core_service_certs.go | 6 +- .../internal/grpc/core_service_dldata_test.go | 3 +- .../grpc/core_service_integrations_test.go | 26 +- .../internal/grpc/core_service_stats_test.go | 8 +- .../core_service_tenant_isolation_test.go | 2 +- KC-Core/internal/grpc/core_service_test.go | 19 +- .../grpc/org_resolver_adapter_test.go | 2 +- .../grpc/org_resolver_interceptor_test.go | 32 +-- KC-Core/internal/grpc/role_resolver_test.go | 4 +- KC-Core/internal/services/activity/service.go | 8 +- .../cached_system_event_store_test.go | 24 +- .../bssci/attach_propagate_complete_test.go | 6 +- .../bssci/blueprint/decoder_service.go | 14 +- .../services/bssci/blueprint/resolver.go | 26 +- .../services/bssci/blueprint/resolver_test.go | 4 +- .../services/bssci/cert_identity_test.go | 10 +- .../services/bssci/connection_service.go | 78 ++---- .../bssci/downlink_dispatcher_test.go | 28 ++- KC-Core/internal/services/bssci/factories.go | 5 +- .../services/bssci/mqtt_adapter_test.go | 14 +- .../bssci/mqtt_downlink_adapter_test.go | 24 +- .../bssci/propagation_service_test.go | 12 +- .../bssci/roaming_integration_test.go | 10 +- .../services/bssci/scaci_forwarder_test.go | 20 +- .../services/bssci/session_resume_db_test.go | 39 +-- .../services/bssci/session_service.go | 8 +- .../internal/services/bssci/status_service.go | 8 +- .../bssci/status_service_integration_test.go | 8 +- .../internal/services/bssci/test_helpers.go | 7 +- .../services/bssci/version_negotiator_test.go | 7 +- .../internal/services/certificates/service.go | 52 ++-- .../services/federation/relay_client.go | 6 +- KC-Core/internal/services/scaci/dl_service.go | 4 +- .../services/scaci/dl_service_test.go | 8 +- .../services/scaci/endpoint_service.go | 30 +-- .../services/scaci/endpoint_service_test.go | 20 +- .../services/scaci/handshake_service.go | 36 +-- .../services/scaci/session_persistence.go | 2 +- .../scaci/session_persistence_test.go | 16 +- KC-Core/internal/services/scaci/ul_service.go | 2 +- .../services/scaci/ul_service_test.go | 16 +- KC-Core/pkg/bssci/cert_enforcement_test.go | 18 +- KC-Core/pkg/bssci/connect_failure_test.go | 8 +- KC-Core/pkg/bssci/connect_handler_test.go | 22 +- KC-Core/pkg/bssci/contracts.go | 20 +- KC-Core/pkg/bssci/detach_integration_test.go | 10 +- .../bssci/detach_propagate_complete_test.go | 15 +- KC-Core/pkg/bssci/detach_validation_test.go | 13 +- KC-Core/pkg/bssci/dlrx_expiry_sweep_test.go | 84 +++++++ KC-Core/pkg/bssci/downlink_handlers.go | 34 +-- KC-Core/pkg/bssci/epstatus_forward_test.go | 25 +- KC-Core/pkg/bssci/log_messages.go | 26 ++ .../bssci/message_normalization_gate_test.go | 164 +------------ KC-Core/pkg/bssci/message_normalizer_test.go | 17 +- KC-Core/pkg/bssci/mqtt_publish_test.go | 24 +- KC-Core/pkg/bssci/observer_test.go | 29 +-- KC-Core/pkg/bssci/outbound_validation_test.go | 7 - .../pkg/bssci/pending_ops_collision_test.go | 11 +- KC-Core/pkg/bssci/phase_c2_regression_test.go | 6 - KC-Core/pkg/bssci/protocol_edge_cases_test.go | 19 +- KC-Core/pkg/bssci/server.go | 100 ++++---- KC-Core/pkg/bssci/session_lookup_test.go | 5 +- KC-Core/pkg/bssci/tenant_context_test.go | 8 +- KC-Core/pkg/bssci/test_helpers_public.go | 2 - KC-Core/pkg/bssci/test_helpers_test.go | 66 +++-- .../pkg/bssci/testutil/recording_logger.go | 136 +++++++++++ KC-Core/pkg/bssci/uldata_multibs_test.go | 4 +- KC-Core/pkg/bssci/version_interop_test.go | 12 +- KC-Core/pkg/bssci/vm_handlers_test.go | 19 +- KC-Core/pkg/grpc/export_constants.go | 31 ++- .../grpc/interceptors/authorization_test.go | 10 +- .../grpc/interceptors/internal_trust_test.go | 10 +- KC-Core/pkg/org/community_test.go | 19 +- KC-Core/pkg/roaming/detector_test.go | 14 +- .../pkg/scaci/broadcast_dldata_result_test.go | 17 +- KC-Core/pkg/scaci/error_recorder_test.go | 16 +- .../scaci/handler_connect_recording_test.go | 38 +-- KC-Core/pkg/scaci/handler_operations_test.go | 8 +- KC-Core/pkg/scaci/handler_status_test.go | 11 +- KC-Core/pkg/scaci/replay_epstatus_test.go | 75 +++--- KC-Core/pkg/scaci/server.go | 12 +- KC-Core/pkg/scaci/server_replay_test.go | 41 ++-- KC-Core/pkg/scaci/server_test.go | 8 +- .../000139_restore_message_archival.down.sql | 15 ++ .../000139_restore_message_archival.up.sql | 23 ++ ..._completed_bssci_pending_operations.up.sql | 37 ++- KC-DB/storage/interfaces/repository.go | 8 +- KC-DB/storage/postgres/archival.go | 96 ++++++-- KC-DB/storage/postgres/archival_test.go | 5 +- .../postgres/basestation_metrics_test.go | 19 +- .../postgres/basestation_repository.go | 6 + .../basestation_session_repository_test.go | 5 +- .../postgres/blueprint_repository_det_test.go | 5 +- KC-DB/storage/postgres/endpoint_repository.go | 6 + .../postgres/endpoint_repository_test.go | 5 +- .../endpoint_session_migration_test.go | 11 +- KC-DB/storage/postgres/legacy_archive.go | 47 ++++ .../postgres/migration_000139_000140_test.go | 227 ++++++++++++++++++ .../pending_operation_repository_test.go | 56 +++++ .../postgres/roaming_repository_test.go | 13 +- .../scaci_operation_repository_test.go | 10 +- .../postgres/scaci_session_guard_test.go | 7 +- .../postgres/scaci_session_repository_test.go | 14 +- .../postgres/system_events_guard_test.go | 13 +- KC-DB/storage/postgres/system_events_test.go | 22 +- KC-DB/storage/postgres/testcontainer_test.go | 7 +- KC-Gateway/internal/integration_test.go | 18 +- .../internal/probe/federation_probe_test.go | 80 ++++-- .../internal/probe/mioty_loop_probe_test.go | 129 +++++++++- KC-Gateway/internal/proxy/metadata_test.go | 5 +- .../internal/proxy/verification_test.go | 139 ++++++++++- .../internal/resilience/rate_limiter_test.go | 4 +- .../internal/grpc/org_resolution_test.go | 36 +-- KC-MQTT/pkg/mqtt/client_test.go | 10 +- KC-MQTT/pkg/mqtt/command_handler_test.go | 36 +-- KC-MQTT/pkg/mqtt/publisher_test.go | 18 +- config/config.docker.yaml | 4 + helm/kilocenter/README.md | 7 + .../templates/kc-core-configmap.yaml | 9 + helm/kilocenter/values.yaml | 9 + 124 files changed, 1984 insertions(+), 1081 deletions(-) create mode 100644 KC-Core/pkg/bssci/dlrx_expiry_sweep_test.go create mode 100644 KC-Core/pkg/bssci/testutil/recording_logger.go create mode 100644 KC-DB/storage/postgres/legacy_archive.go create mode 100644 KC-DB/storage/postgres/migration_000139_000140_test.go create mode 100644 KC-DB/storage/postgres/pending_operation_repository_test.go diff --git a/KC-Core/cmd/kilocenter/builders/protocol.go b/KC-Core/cmd/kilocenter/builders/protocol.go index 35448d0..77d6568 100644 --- a/KC-Core/cmd/kilocenter/builders/protocol.go +++ b/KC-Core/cmd/kilocenter/builders/protocol.go @@ -101,6 +101,7 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol infra.Storage, infra.SystemEventStore, infra.QueueStore, + infra.ConnectionMgr, infra.LoggerIface, infra.TenantID, infra.OrgResolverSvc, diff --git a/KC-Core/config.ce-test.yaml b/KC-Core/config.ce-test.yaml index 3da9aa8..3876b16 100644 --- a/KC-Core/config.ce-test.yaml +++ b/KC-Core/config.ce-test.yaml @@ -20,8 +20,13 @@ storage: protocol: bsci_host: "0.0.0.0" bsci_port: 5002 + # BSSCI requires mutual TLS; the harness shares the dev certificates. bsci_tls: - enabled: false + enabled: true + cert_file: "certificates/server.crt" + key_file: "certificates/server.key" + ca_file: "certificates/ca.crt" + min_version: "1.2" scaci_enabled: false federation: diff --git a/KC-Core/config/config.yaml b/KC-Core/config/config.yaml index 60171a7..9c57bee 100644 --- a/KC-Core/config/config.yaml +++ b/KC-Core/config/config.yaml @@ -38,6 +38,10 @@ protocol: dlrx_cleanup_interval: 60 duplicate_window: 300 message_encoding: "msgpack" + bsci_certificate_poll_interval: "10s" + # Service Center EUI. Empty defers to KILOCENTER_PROTOCOL_SC_EUI, the legacy + # SERVICE_CENTER_EUI environment variable, or the built-in default. + sc_eui: "" storage: type: "postgres" diff --git a/KC-Core/internal/grpc/auth_interceptor_test.go b/KC-Core/internal/grpc/auth_interceptor_test.go index e5bf1df..e3bfe51 100644 --- a/KC-Core/internal/grpc/auth_interceptor_test.go +++ b/KC-Core/internal/grpc/auth_interceptor_test.go @@ -14,6 +14,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -97,7 +99,7 @@ func TestAuthInterceptor_OpaqueToken_CallsAPIKeyAuth(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + rawToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler, captured := captureHandler() _, err = ai.UnaryInterceptor()(ctx, nil, nonPublicInfo(), handler) @@ -136,7 +138,7 @@ func TestAuthInterceptor_JWTShaped_NotAPIKeyFallback(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + jwtToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -180,7 +182,7 @@ func TestAuthInterceptor_APIKey_ValidUserKey(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + rawToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler, captured := captureHandler() _, err = ai.UnaryInterceptor()(ctx, nil, nonPublicInfo(), handler) @@ -243,7 +245,7 @@ func TestAuthInterceptor_APIKey_ValidServiceAccountKey(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + rawToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler, captured := captureHandler() _, err = ai.UnaryInterceptor()(ctx, nil, nonPublicInfo(), handler) @@ -294,7 +296,7 @@ func TestAuthInterceptor_APIKey_Expired(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + rawToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -336,7 +338,7 @@ func TestAuthInterceptor_APIKey_Inactive(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + rawToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -375,7 +377,7 @@ func TestAuthInterceptor_APIKey_UnknownHash(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + rawToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -412,7 +414,7 @@ func TestAuthInterceptor_OpaqueToken_NoAPIKeyAuth(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: grpcerrors.BearerPrefix + opaqueToken, }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -447,7 +449,7 @@ func TestAuthInterceptor_AuthDisabled(t *testing.T) { } return "ok", nil } - _, err = ai.UnaryInterceptor()(context.Background(), nil, nonPublicInfo(), handler) + _, err = ai.UnaryInterceptor()(testutil.TestContext(), nil, nonPublicInfo(), handler) if err != nil { t.Fatalf("unexpected error when auth disabled: %v", err) } @@ -466,7 +468,7 @@ func TestAuthInterceptor_MissingMetadata(t *testing.T) { t.Error("handler should not be called") return nil, nil } - _, err = ai.UnaryInterceptor()(context.Background(), nil, nonPublicInfo(), handler) + _, err = ai.UnaryInterceptor()(testutil.TestContext(), nil, nonPublicInfo(), handler) if err == nil { t.Fatal("expected error for missing metadata") } @@ -490,7 +492,7 @@ func TestAuthInterceptor_MissingBearerPrefix(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyAuthorization: "NotBearer sometoken", }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") diff --git a/KC-Core/internal/grpc/core_service_certs.go b/KC-Core/internal/grpc/core_service_certs.go index 159a9d8..dcf809d 100644 --- a/KC-Core/internal/grpc/core_service_certs.go +++ b/KC-Core/internal/grpc/core_service_certs.go @@ -38,7 +38,7 @@ func (s *CoreService) GenerateCertificate(ctx context.Context, req *pb.GenerateC // minting a certificate for any EUI. tenantID, tenantErr := GetTenantFromContext(ctx) if tenantErr != nil || tenantID <= 0 { - s.log.WarnContext(ctx, "certificate issuance requires tenant context", "bs_eui", req.BsEui, "error", tenantErr) + s.log.WarnContext(ctx, grpcerrors.LogCertIssuanceRequiresTenant, "bs_eui", req.BsEui, "error", tenantErr) return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenMissingTenantCtx), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenMissingTenantCtx)) } @@ -52,7 +52,7 @@ func (s *CoreService) GenerateCertificate(ctx context.Context, req *pb.GenerateC resp, err := s.certSvc.GenerateCertificate(ctx, certReq) if err != nil { - s.log.ErrorContext(ctx, "generate certificate failed", "bs_eui", req.BsEui, "error", err) + s.log.ErrorContext(ctx, grpcerrors.LogGenerateCertificateFailed, "bs_eui", req.BsEui, "error", err) // Typed catalog errors survive the service boundary: match on the // token, never on error text @@ -236,7 +236,7 @@ func (s *CoreService) GetServerCertificateStatus(ctx context.Context, _ *pb.GetS certStatus, err := s.certSvc.GetServerCertificateStatus(ctx) if err != nil { - s.log.ErrorContext(ctx, "get server certificate status failed", "error", err) + s.log.ErrorContext(ctx, grpcerrors.LogGetServerCertStatusFailed, "error", err) return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenCertStatusFailed), grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenCertStatusFailed)) } diff --git a/KC-Core/internal/grpc/core_service_dldata_test.go b/KC-Core/internal/grpc/core_service_dldata_test.go index 780a041..ce46c92 100644 --- a/KC-Core/internal/grpc/core_service_dldata_test.go +++ b/KC-Core/internal/grpc/core_service_dldata_test.go @@ -287,7 +287,8 @@ func (f *fakeStatusSvc) RecordPendingOperations(_ context.Context, _ *bssci.Sess return nil } -func (f *fakeStatusSvc) RestorePendingOperation(_ *bssci.Session, _ int64, _ *bssci.PendingOperation) {} +func (f *fakeStatusSvc) RestorePendingOperation(_ *bssci.Session, _ int64, _ *bssci.PendingOperation) { +} func (f *fakeStatusSvc) GetPendingOperation(_ *bssci.Session, _ int64) (*bssci.PendingOperation, error) { return nil, nil } diff --git a/KC-Core/internal/grpc/core_service_integrations_test.go b/KC-Core/internal/grpc/core_service_integrations_test.go index dea1cda..7206ea3 100644 --- a/KC-Core/internal/grpc/core_service_integrations_test.go +++ b/KC-Core/internal/grpc/core_service_integrations_test.go @@ -20,6 +20,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" pkgcontext "github.com/Kiloiot/kilo-service-center/pkg/context" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -125,7 +127,7 @@ func TestCreateIntegration_ServiceNotConfigured(t *testing.T) { svc := createTestIntegrationService() // integrationSvc is nil by default - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.CreateIntegrationRequest{ Name: "Test", @@ -146,7 +148,7 @@ func TestCreateIntegration_MissingTenant(t *testing.T) { svc.integrationSvc = mockSvc // Context without tenant ID - ctx := context.Background() + ctx := testutil.TestContext() req := &pb.CreateIntegrationRequest{ Name: "Test", @@ -166,7 +168,7 @@ func TestCreateIntegration_MissingName(t *testing.T) { svc := createTestIntegrationService() svc.integrationSvc = mockSvc - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) ctx = pkgcontext.WithOrganizationID(ctx, uuid.New()) req := &pb.CreateIntegrationRequest{ @@ -187,7 +189,7 @@ func TestCreateIntegration_MissingConfig(t *testing.T) { svc := createTestIntegrationService() svc.integrationSvc = mockSvc - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) ctx = pkgcontext.WithOrganizationID(ctx, uuid.New()) req := &pb.CreateIntegrationRequest{ @@ -217,7 +219,7 @@ func TestCreateIntegration_Success(t *testing.T) { svc := createTestIntegrationService() svc.integrationSvc = mockSvc - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) ctx = pkgcontext.WithOrganizationID(ctx, integration.OrgID) configStruct, err := structpb.NewStruct(map[string]interface{}{ @@ -249,7 +251,7 @@ func TestCreateIntegration_Success(t *testing.T) { func TestGetIntegration_ServiceNotConfigured(t *testing.T) { svc := createTestIntegrationService() - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.GetIntegrationRequest{Id: 1} @@ -266,7 +268,7 @@ func TestGetIntegration_MissingID(t *testing.T) { svc := createTestIntegrationService() svc.integrationSvc = mockSvc - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.GetIntegrationRequest{Id: 0} @@ -290,7 +292,7 @@ func TestGetIntegration_Success(t *testing.T) { svc := createTestIntegrationService() svc.integrationSvc = mockSvc - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.GetIntegrationRequest{Id: 1} @@ -309,7 +311,7 @@ func TestGetIntegration_Success(t *testing.T) { func TestListIntegrations_ServiceNotConfigured(t *testing.T) { svc := createTestIntegrationService() - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.ListIntegrationsRequest{} @@ -334,7 +336,7 @@ func TestListIntegrations_Success(t *testing.T) { svc := createTestIntegrationService() svc.integrationSvc = mockSvc - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.ListIntegrationsRequest{} @@ -354,7 +356,7 @@ func TestListIntegrations_Success(t *testing.T) { func TestDeleteIntegration_ServiceNotConfigured(t *testing.T) { svc := createTestIntegrationService() - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.DeleteIntegrationRequest{Id: 1} @@ -377,7 +379,7 @@ func TestDeleteIntegration_Success(t *testing.T) { svc := createTestIntegrationService() svc.integrationSvc = mockSvc - ctx := pkgcontext.WithTenantID(context.Background(), 100) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), 100) req := &pb.DeleteIntegrationRequest{Id: 1} diff --git a/KC-Core/internal/grpc/core_service_stats_test.go b/KC-Core/internal/grpc/core_service_stats_test.go index 756ceb1..a4da671 100644 --- a/KC-Core/internal/grpc/core_service_stats_test.go +++ b/KC-Core/internal/grpc/core_service_stats_test.go @@ -14,6 +14,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" pkgcontext "github.com/Kiloiot/kilo-service-center/pkg/context" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // MockStorage implements storage.Storage for testing @@ -317,7 +319,7 @@ func TestGetBaseStationStats_StorageMethods(t *testing.T) { &lastWeek, &now).Return(mockStats, nil).Once() // Execute - stats, err := mockStorage.GetBaseStationMessageStats(pkgcontext.WithTenantID(context.Background(), tenantID), tenantID, bsEuiBytes, &lastWeek, &now) + stats, err := mockStorage.GetBaseStationMessageStats(pkgcontext.WithTenantID(testutil.TestContext(), tenantID), tenantID, bsEuiBytes, &lastWeek, &now) // Assert assert.NoError(t, err) @@ -334,7 +336,7 @@ func TestGetBaseStationStats_StorageMethods(t *testing.T) { mockStorage.On("GetBaseStationEndpointCounts", mock.Anything, tenantID, bsEuiBytes, &lastWeek, &now).Return(endpointCounts, nil).Once() - counts, err := mockStorage.GetBaseStationEndpointCounts(pkgcontext.WithTenantID(context.Background(), tenantID), tenantID, bsEuiBytes, &lastWeek, &now) + counts, err := mockStorage.GetBaseStationEndpointCounts(pkgcontext.WithTenantID(testutil.TestContext(), tenantID), tenantID, bsEuiBytes, &lastWeek, &now) assert.NoError(t, err) assert.Len(t, counts, 2) assert.Equal(t, int64(100), counts["AABBCCDDEE112233"]) @@ -344,7 +346,7 @@ func TestGetBaseStationStats_StorageMethods(t *testing.T) { mockStorage.On("GetBaseStationLastSeen", mock.Anything, tenantID, bsEuiBytes). Return(&lastSeen, nil).Once() - seenTime, err := mockStorage.GetBaseStationLastSeen(pkgcontext.WithTenantID(context.Background(), tenantID), tenantID, bsEuiBytes) + seenTime, err := mockStorage.GetBaseStationLastSeen(pkgcontext.WithTenantID(testutil.TestContext(), tenantID), tenantID, bsEuiBytes) assert.NoError(t, err) assert.NotNil(t, seenTime) assert.Equal(t, lastSeen, *seenTime) diff --git a/KC-Core/internal/grpc/core_service_tenant_isolation_test.go b/KC-Core/internal/grpc/core_service_tenant_isolation_test.go index cd57f4f..5b28df0 100644 --- a/KC-Core/internal/grpc/core_service_tenant_isolation_test.go +++ b/KC-Core/internal/grpc/core_service_tenant_isolation_test.go @@ -414,7 +414,7 @@ func TestTenantIsolation_GetMessage_TenantPropagation(t *testing.T) { func TestTenantIsolation_MissingTenant_FailsClosed(t *testing.T) { ts := newIsolationTestService() - ctx := context.Background() // No tenant + ctx := testutil.TestContext() // No tenant tests := []struct { name string diff --git a/KC-Core/internal/grpc/core_service_test.go b/KC-Core/internal/grpc/core_service_test.go index c7bd9c6..bc0591f 100644 --- a/KC-Core/internal/grpc/core_service_test.go +++ b/KC-Core/internal/grpc/core_service_test.go @@ -1055,7 +1055,7 @@ func TestGRPCCreateEndpoint_MissingTenant(t *testing.T) { } // Intentionally bare context — tests unauthenticated failure path - ctx := context.Background() + ctx := testutil.TestContext() req := &pb.CreateEndPointRequest{ Endpoint: &pb.EndPoint{EpEui: "0x1122334455667788", Name: "Test", NwkSnKey: make([]byte, 16)}, @@ -2244,7 +2244,8 @@ func (s *stubStatusSvc) RecordPendingOperations(_ context.Context, _ *bssci.Sess return nil } -func (s *stubStatusSvc) RestorePendingOperation(_ *bssci.Session, _ int64, _ *bssci.PendingOperation) {} +func (s *stubStatusSvc) RestorePendingOperation(_ *bssci.Session, _ int64, _ *bssci.PendingOperation) { +} func (s *stubStatusSvc) GetPendingOperation(_ *bssci.Session, _ int64) (*bssci.PendingOperation, error) { return nil, nil } @@ -3898,7 +3899,7 @@ func TestListAllBaseStationLocations(t *testing.T) { orgMapper: &locOrgMapper{}, } - ctx := pkgcontext.WithUserID(context.Background(), adminUserID) + ctx := pkgcontext.WithUserID(testutil.TestContext(), adminUserID) resp, err := svc.ListAllBaseStationLocations(ctx, &pb.ListAllBaseStationLocationsRequest{}) require.Error(t, err) @@ -3933,7 +3934,7 @@ func TestListAllBaseStationLocations(t *testing.T) { }, } - ctx := pkgcontext.WithUserID(context.Background(), adminUserID) + ctx := pkgcontext.WithUserID(testutil.TestContext(), adminUserID) resp, err := svc.ListAllBaseStationLocations(ctx, &pb.ListAllBaseStationLocationsRequest{}) require.NoError(t, err) @@ -3958,7 +3959,7 @@ func TestListAllBaseStationLocations(t *testing.T) { orgMapper: &locOrgMapper{}, } - ctx := context.Background() + ctx := testutil.TestContext() resp, err := svc.ListAllBaseStationLocations(ctx, &pb.ListAllBaseStationLocationsRequest{}) require.Error(t, err) assert.Nil(t, resp) @@ -3971,7 +3972,7 @@ func TestListAllBaseStationLocations(t *testing.T) { orgMapper: &locOrgMapper{}, } - ctx := pkgcontext.WithUserID(context.Background(), adminUserID) + ctx := pkgcontext.WithUserID(testutil.TestContext(), adminUserID) resp, err := svc.ListAllBaseStationLocations(ctx, &pb.ListAllBaseStationLocationsRequest{}) require.Error(t, err) @@ -4002,7 +4003,7 @@ func TestListAllBaseStationLocations(t *testing.T) { }, } - ctx := pkgcontext.WithUserID(context.Background(), adminUserID) + ctx := pkgcontext.WithUserID(testutil.TestContext(), adminUserID) resp, err := svc.ListAllBaseStationLocations(ctx, &pb.ListAllBaseStationLocationsRequest{}) require.Error(t, err) @@ -4019,7 +4020,7 @@ func TestListAllBaseStationLocations(t *testing.T) { orgMapper: &locOrgMapper{}, } - ctx := pkgcontext.WithUserID(context.Background(), adminUserID) + ctx := pkgcontext.WithUserID(testutil.TestContext(), adminUserID) resp, err := svc.ListAllBaseStationLocations(ctx, &pb.ListAllBaseStationLocationsRequest{}) require.Error(t, err) @@ -4043,7 +4044,7 @@ func TestListAllBaseStationLocations(t *testing.T) { // orgMapper intentionally nil } - ctx := pkgcontext.WithUserID(context.Background(), adminUserID) + ctx := pkgcontext.WithUserID(testutil.TestContext(), adminUserID) resp, err := svc.ListAllBaseStationLocations(ctx, &pb.ListAllBaseStationLocationsRequest{}) require.Error(t, err) diff --git a/KC-Core/internal/grpc/org_resolver_adapter_test.go b/KC-Core/internal/grpc/org_resolver_adapter_test.go index b8ed7da..b6a2ab4 100644 --- a/KC-Core/internal/grpc/org_resolver_adapter_test.go +++ b/KC-Core/internal/grpc/org_resolver_adapter_test.go @@ -190,7 +190,7 @@ func (l *capturingLogger) FatalContext(ctx context.Context, msg string, fields . } func (l *capturingLogger) capture(level string, msg string, fields ...interface{}) { - l.captureWithContext(context.Background(), level, msg, fields...) + l.captureWithContext(testutil.TestContext(), level, msg, fields...) } func (l *capturingLogger) WithField(_ string, _ interface{}) logger.Logger { diff --git a/KC-Core/internal/grpc/org_resolver_interceptor_test.go b/KC-Core/internal/grpc/org_resolver_interceptor_test.go index 1a121f4..4cd2eef 100644 --- a/KC-Core/internal/grpc/org_resolver_interceptor_test.go +++ b/KC-Core/internal/grpc/org_resolver_interceptor_test.go @@ -12,6 +12,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockOrgResolver implements org.Resolver for testing @@ -73,7 +75,7 @@ func TestOrgResolverInterceptor_MissingMetadata(t *testing.T) { }) // Create context without metadata - ctx := context.Background() + ctx := testutil.TestContext() handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -106,7 +108,7 @@ func TestOrgResolverInterceptor_MissingOrgID(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyUserID: uuid.New().String(), }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -140,7 +142,7 @@ func TestOrgResolverInterceptor_InvalidOrgID(t *testing.T) { grpcerrors.MetadataKeyOrganizationID: "not-a-uuid", grpcerrors.MetadataKeyUserID: uuid.New().String(), }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -173,7 +175,7 @@ func TestOrgResolverInterceptor_MissingUserID(t *testing.T) { md := metadata.New(map[string]string{ grpcerrors.MetadataKeyOrganizationID: uuid.New().String(), }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -207,7 +209,7 @@ func TestOrgResolverInterceptor_InvalidUserID(t *testing.T) { grpcerrors.MetadataKeyOrganizationID: uuid.New().String(), grpcerrors.MetadataKeyUserID: "not-a-uuid", }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -245,7 +247,7 @@ func TestOrgResolverInterceptor_ResolutionFailure(t *testing.T) { grpcerrors.MetadataKeyOrganizationID: orgID.String(), grpcerrors.MetadataKeyUserID: userID.String(), }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called") @@ -281,7 +283,7 @@ func TestOrgResolverInterceptor_Success(t *testing.T) { grpcerrors.MetadataKeyOrganizationID: orgID.String(), grpcerrors.MetadataKeyUserID: userID.String(), }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) var handlerCtx context.Context handler := func(ctx context.Context, _ interface{}) (interface{}, error) { @@ -341,7 +343,7 @@ func TestOrgResolverInterceptor_SkipMethods(t *testing.T) { }) // Create context without metadata (would normally fail) - ctx := context.Background() + ctx := testutil.TestContext() handlerCalled := false handler := func(_ context.Context, _ interface{}) (interface{}, error) { @@ -381,7 +383,7 @@ func TestOrgResolverInterceptor_AuthIdentity_TenantMatch(t *testing.T) { }) // Build context with pre-established auth identity - ctx := pkgcontext.WithTenantID(context.Background(), tenantID) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), tenantID) ctx = pkgcontext.WithOrganizationID(ctx, orgID) ctx = pkgcontext.WithUserID(ctx, userID.String()) @@ -423,7 +425,7 @@ func TestOrgResolverInterceptor_AuthIdentity_TenantMismatch(t *testing.T) { }) // Auth established tenant 42 - ctx := pkgcontext.WithTenantID(context.Background(), authTenantID) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), authTenantID) ctx = pkgcontext.WithOrganizationID(ctx, orgID) ctx = pkgcontext.WithUserID(ctx, userID.String()) @@ -468,7 +470,7 @@ func TestOrgResolverInterceptor_AuthIdentity_OrgMismatch(t *testing.T) { }) // Auth established with authOrgID - ctx := pkgcontext.WithTenantID(context.Background(), tenantID) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), tenantID) ctx = pkgcontext.WithOrganizationID(ctx, authOrgID) ctx = pkgcontext.WithUserID(ctx, userID.String()) @@ -514,7 +516,7 @@ func TestOrgResolverInterceptor_AuthIdentity_UserMismatch(t *testing.T) { }) // Auth established with authUserID - ctx := pkgcontext.WithTenantID(context.Background(), tenantID) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), tenantID) ctx = pkgcontext.WithOrganizationID(ctx, orgID) ctx = pkgcontext.WithUserID(ctx, authUserID.String()) @@ -558,7 +560,7 @@ func TestOrgResolverInterceptor_ServiceAccountPrincipal_NoUserHeader(t *testing. }) // Auth established tenant+org but NO user (service-account principal) - ctx := pkgcontext.WithTenantID(context.Background(), tenantID) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), tenantID) ctx = pkgcontext.WithOrganizationID(ctx, orgID) // Header only has org, no user @@ -597,7 +599,7 @@ func TestOrgResolverInterceptor_ServiceAccountPrincipal_UserHeaderRejected(t *te }) // Auth established tenant+org but NO user (service-account principal) - ctx := pkgcontext.WithTenantID(context.Background(), tenantID) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), tenantID) ctx = pkgcontext.WithOrganizationID(ctx, orgID) // Header has org AND user (spoofing attempt) @@ -645,7 +647,7 @@ func TestOrgResolverInterceptor_NoAuthIdentity_SetsFromHeaders(t *testing.T) { grpcerrors.MetadataKeyOrganizationID: orgID.String(), grpcerrors.MetadataKeyUserID: userID.String(), }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) var handlerCtx context.Context handler := func(ctx context.Context, _ interface{}) (interface{}, error) { diff --git a/KC-Core/internal/grpc/role_resolver_test.go b/KC-Core/internal/grpc/role_resolver_test.go index c0a84e7..8a83b90 100644 --- a/KC-Core/internal/grpc/role_resolver_test.go +++ b/KC-Core/internal/grpc/role_resolver_test.go @@ -11,6 +11,8 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/test/bufconn" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) type mockIdentityServer struct { @@ -54,7 +56,7 @@ func TestRoleResolver_SendsPeerSecretHeader(t *testing.T) { client := pb.NewIdentityInternalServiceClient(conn) resolver := NewRoleResolver(client, "test-secret") - role, active, err := resolver.GetUserRole(context.Background(), "org-1", "user-1") + role, active, err := resolver.GetUserRole(testutil.TestContext(), "org-1", "user-1") if err != nil { t.Fatalf("GetUserRole returned error: %v", err) } diff --git a/KC-Core/internal/services/activity/service.go b/KC-Core/internal/services/activity/service.go index a4eb806..83c28f1 100644 --- a/KC-Core/internal/services/activity/service.go +++ b/KC-Core/internal/services/activity/service.go @@ -78,7 +78,7 @@ func (s *Service) ListBaseStationActivity( fetchLimit := pageSize * 2 events, eventTotal, err := s.eventSvc.ListByBaseStation(ctx, tenantID, bsEui, eventFilters, fetchLimit, cursor.EventOffset) if err != nil { - s.log.Error("failed to fetch events for activity feed", "error", err, "bsEui", bsEui) + s.log.ErrorContext(ctx, "failed to fetch events for activity feed", "error", err, "bsEui", bsEui) events = []*grpcservices.Event{} eventTotal = 0 } @@ -86,7 +86,7 @@ func (s *Service) ListBaseStationActivity( // Fetch messages messages, messageTotal, err := s.messageSvc.ListBaseStationMessages(ctx, tenantID, bsEui, messageFilters, fetchLimit, cursor.MessageOffset) if err != nil { - s.log.Error("failed to fetch messages for activity feed", "error", err, "bsEui", bsEui) + s.log.ErrorContext(ctx, "failed to fetch messages for activity feed", "error", err, "bsEui", bsEui) messages = nil messageTotal = 0 } @@ -198,14 +198,14 @@ func (s *Service) ListEndpointActivity( fetchLimit := pageSize * 2 events, eventTotal, err := s.eventSvc.ListByEndPoint(ctx, tenantID, epEui, eventFilters, fetchLimit, cursor.EventOffset) if err != nil { - s.log.Error("failed to fetch events for endpoint activity feed", "error", err, "epEui", epEui) + s.log.ErrorContext(ctx, "failed to fetch events for endpoint activity feed", "error", err, "epEui", epEui) events = []*grpcservices.Event{} eventTotal = 0 } messages, messageTotal, err := s.messageSvc.ListMessages(ctx, tenantID, messageFilters, fetchLimit, cursor.MessageOffset) if err != nil { - s.log.Error("failed to fetch messages for endpoint activity feed", "error", err, "epEui", epEui) + s.log.ErrorContext(ctx, "failed to fetch messages for endpoint activity feed", "error", err, "epEui", epEui) messages = nil messageTotal = 0 } diff --git a/KC-Core/internal/services/adapters/cached_system_event_store_test.go b/KC-Core/internal/services/adapters/cached_system_event_store_test.go index efb7989..a23b5f4 100644 --- a/KC-Core/internal/services/adapters/cached_system_event_store_test.go +++ b/KC-Core/internal/services/adapters/cached_system_event_store_test.go @@ -10,6 +10,8 @@ import ( eventsservice "github.com/Kiloiot/kilo-service-center/KC-Core/internal/services/events" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) type fakeStore struct { @@ -41,11 +43,11 @@ func TestCachedCount_HitWithinTTL(t *testing.T) { c := NewCachedSystemEventStore(inner, 10*time.Second) f := &eventsservice.EventFilter{Categories: []string{"a"}} - v1, err := c.CountEvents(context.Background(), 1, f) + v1, err := c.CountEvents(testutil.TestContext(), 1, f) if err != nil || v1 != 42 { t.Fatalf("first: got (%d,%v), want (42,nil)", v1, err) } - v2, _ := c.CountEvents(context.Background(), 1, f) + v2, _ := c.CountEvents(testutil.TestContext(), 1, f) if v2 != 42 { t.Fatalf("second: got %d, want 42", v2) } @@ -61,9 +63,9 @@ func TestCachedCount_ExpiryAfterTTL(t *testing.T) { c.now = func() time.Time { return now } f := &eventsservice.EventFilter{Categories: []string{"a"}} - _, _ = c.CountEvents(context.Background(), 1, f) + _, _ = c.CountEvents(testutil.TestContext(), 1, f) now = now.Add(11 * time.Second) // past TTL - _, _ = c.CountEvents(context.Background(), 1, f) + _, _ = c.CountEvents(testutil.TestContext(), 1, f) if got := atomic.LoadInt32(&inner.countCalls); got != 2 { t.Fatalf("inner count calls = %d, want 2 (expired)", got) @@ -75,10 +77,10 @@ func TestCachedCount_ErrorsNotCached(t *testing.T) { c := NewCachedSystemEventStore(inner, 10*time.Second) f := &eventsservice.EventFilter{Categories: []string{"a"}} - if _, err := c.CountEvents(context.Background(), 1, f); err == nil { + if _, err := c.CountEvents(testutil.TestContext(), 1, f); err == nil { t.Fatal("want error") } - if _, err := c.CountEvents(context.Background(), 1, f); err == nil { + if _, err := c.CountEvents(testutil.TestContext(), 1, f); err == nil { t.Fatal("want error") } if got := atomic.LoadInt32(&inner.countCalls); got != 2 { @@ -89,7 +91,7 @@ func TestCachedCount_ErrorsNotCached(t *testing.T) { func TestCachedCount_KeyVariesByFilterAndTenant(t *testing.T) { inner := &fakeStore{countVal: 1} c := NewCachedSystemEventStore(inner, 10*time.Second) - ctx := context.Background() + ctx := testutil.TestContext() _, _ = c.CountEvents(ctx, 1, &eventsservice.EventFilter{Categories: []string{"a"}}) // miss -> 1 _, _ = c.CountEvents(ctx, 1, &eventsservice.EventFilter{Categories: []string{"b"}}) // miss -> 2 @@ -105,7 +107,7 @@ func TestCachedCount_KeyVariesByFilterAndTenant(t *testing.T) { func TestCachedCount_SortedCategoriesShareKey(t *testing.T) { inner := &fakeStore{countVal: 1} c := NewCachedSystemEventStore(inner, 10*time.Second) - ctx := context.Background() + ctx := testutil.TestContext() _, _ = c.CountEvents(ctx, 1, &eventsservice.EventFilter{Categories: []string{"a", "b"}}) _, _ = c.CountEvents(ctx, 1, &eventsservice.EventFilter{Categories: []string{"b", "a"}}) // same key @@ -119,8 +121,8 @@ func TestCachedCount_GetEventsPassthrough(t *testing.T) { inner := &fakeStore{} c := NewCachedSystemEventStore(inner, 10*time.Second) - _, _ = c.GetEvents(context.Background(), 1, nil, 10, 0) - _, _ = c.GetEvents(context.Background(), 1, nil, 10, 0) + _, _ = c.GetEvents(testutil.TestContext(), 1, nil, 10, 0) + _, _ = c.GetEvents(testutil.TestContext(), 1, nil, 10, 0) if got := atomic.LoadInt32(&inner.getCalls); got != 2 { t.Fatalf("inner GetEvents calls = %d, want 2 (never cached)", got) @@ -140,7 +142,7 @@ func TestCachedCount_SingleflightCollapse(t *testing.T) { wg.Add(1) go func(i int) { defer wg.Done() - v, _ := c.CountEvents(context.Background(), 1, f) + v, _ := c.CountEvents(testutil.TestContext(), 1, f) results[i] = v }(i) } diff --git a/KC-Core/internal/services/bssci/attach_propagate_complete_test.go b/KC-Core/internal/services/bssci/attach_propagate_complete_test.go index ffbaefe..960df1e 100644 --- a/KC-Core/internal/services/bssci/attach_propagate_complete_test.go +++ b/KC-Core/internal/services/bssci/attach_propagate_complete_test.go @@ -9,6 +9,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestAttachPropagateCompletionMetadataExtraction validates that the attPrpCmp @@ -162,7 +164,7 @@ func TestAttachPropagateCompletionPersistence(t *testing.T) { } // Call mock store - err := mockStore.CreateAttachPropagateMessage(context.Background(), completionMsg) + err := mockStore.CreateAttachPropagateMessage(testutil.TestContext(), completionMsg) require.NoError(t, err) // Verify message was captured @@ -201,7 +203,7 @@ func TestAttachPropagateCompletionNoPendingOp(t *testing.T) { Status: pkgbssci.EventStatusNew, } - err := mockEventStore.CreateEvent(context.Background(), completionEvent) + err := mockEventStore.CreateEvent(testutil.TestContext(), completionEvent) require.NoError(t, err) // Verify event was created diff --git a/KC-Core/internal/services/bssci/blueprint/decoder_service.go b/KC-Core/internal/services/bssci/blueprint/decoder_service.go index 5f65771..6aa1e8b 100644 --- a/KC-Core/internal/services/bssci/blueprint/decoder_service.go +++ b/KC-Core/internal/services/bssci/blueprint/decoder_service.go @@ -32,7 +32,7 @@ func NewDecoderService(log logger.Logger) *DecoderService { // Decode decodes raw payload using blueprint spec for given format ID. // Implements the BlueprintDecoder interface. func (s *DecoderService) Decode( - _ context.Context, + ctx context.Context, bp *models.Blueprint, userData []byte, formatID uint8, @@ -41,7 +41,7 @@ func (s *DecoderService) Decode( // Parse the blueprint specification spec, err := s.parseBlueprint(bp.SpecJSON) if err != nil { - s.log.Warn("Failed to parse blueprint specification", + s.log.WarnContext(ctx, "Failed to parse blueprint specification", "blueprint_id", bp.ID, "error", err) return blueprint.NewDecodeError(blueprint.ErrInvalidBlueprintJSON, err.Error()), nil @@ -49,7 +49,7 @@ func (s *DecoderService) Decode( // Validate the specification if err := spec.Validate(); err != nil { - s.log.Warn("Blueprint validation failed", + s.log.WarnContext(ctx, "Blueprint validation failed", "blueprint_id", bp.ID, "error", err) if ve, ok := err.(*blueprint.ValidationError); ok { @@ -61,7 +61,7 @@ func (s *DecoderService) Decode( // Find the format definition for the given format ID format := spec.GetUplinkFormat(formatID) if format == nil { - s.log.Debug("Format ID not found in blueprint", + s.log.DebugContext(ctx, "Format ID not found in blueprint", "blueprint_id", bp.ID, "format_id", formatID) return blueprint.NewDecodeError( @@ -74,7 +74,7 @@ func (s *DecoderService) Decode( requiredBits := s.calculateRequiredBits(format) requiredBytes := (requiredBits + 7) / 8 if len(userData) < requiredBytes { - s.log.Debug("Payload too short for format", + s.log.DebugContext(ctx, "Payload too short for format", "blueprint_id", bp.ID, "format_id", formatID, "required_bytes", requiredBytes, @@ -91,7 +91,7 @@ func (s *DecoderService) Decode( // Decode the payload decodedData, err := s.decodePayload(userData, format, mergedCalibration) if err != nil { - s.log.Debug("Payload decode failed", + s.log.DebugContext(ctx, "Payload decode failed", "blueprint_id", bp.ID, "format_id", formatID, "error", err) @@ -101,7 +101,7 @@ func (s *DecoderService) Decode( return blueprint.NewDecodeError(blueprint.ErrInternalDecodePanic, err.Error()), nil } - s.log.Debug("Payload decoded successfully", + s.log.DebugContext(ctx, "Payload decoded successfully", "blueprint_id", bp.ID, "format_id", formatID, "field_count", len(decodedData)) diff --git a/KC-Core/internal/services/bssci/blueprint/resolver.go b/KC-Core/internal/services/bssci/blueprint/resolver.go index e7b4b13..9976680 100644 --- a/KC-Core/internal/services/bssci/blueprint/resolver.go +++ b/KC-Core/internal/services/bssci/blueprint/resolver.go @@ -49,14 +49,14 @@ func (s *ResolverService) ResolveBlueprint( ) (*models.Blueprint, error) { // If typeEUI is nil or empty, we can't resolve if len(typeEUI) == 0 { - s.log.Debug("No Type EUI provided, skipping blueprint resolution") + s.log.DebugContext(ctx, "No Type EUI provided, skipping blueprint resolution") return nil, nil } // Try to find blueprint by Type EUI directly bp, err := s.blueprintRepo.GetByTypeEUI(ctx, tenantID, typeEUI) if err == nil && bp != nil { - s.log.Debug("Found blueprint by Type EUI", + s.log.DebugContext(ctx, "Found blueprint by Type EUI", "blueprint_id", bp.ID, "type_eui", hex.EncodeToString(typeEUI), "version", bp.Version) @@ -65,12 +65,12 @@ func (s *ResolverService) ResolveBlueprint( // Log if error is not "not found" if err != nil && !isNotFoundError(err) { - s.log.Warn("Error looking up blueprint by Type EUI", + s.log.WarnContext(ctx, "Error looking up blueprint by Type EUI", "type_eui", hex.EncodeToString(typeEUI), "error", err) } - s.log.Debug("No blueprint found for Type EUI", + s.log.DebugContext(ctx, "No blueprint found for Type EUI", "type_eui", hex.EncodeToString(typeEUI)) return nil, nil } @@ -89,13 +89,13 @@ func (s *ResolverService) ResolveBlueprintForEndpoint( if len(endpoint.BlueprintSnapshot) > 0 { var snap models.BlueprintSnapshot if err := json.Unmarshal(endpoint.BlueprintSnapshot, &snap); err != nil { - s.log.Warn("failed to parse endpoint blueprint snapshot", + s.log.WarnContext(ctx, "failed to parse endpoint blueprint snapshot", "endpoint_eui", endpoint.EUI.String(), "error", err) } else if bp, err := snap.ToBlueprint(); err != nil { - s.log.Warn("invalid endpoint blueprint snapshot", + s.log.WarnContext(ctx, "invalid endpoint blueprint snapshot", "endpoint_eui", endpoint.EUI.String(), "error", err) } else { - s.log.Debug("Resolved blueprint from endpoint snapshot", + s.log.DebugContext(ctx, "Resolved blueprint from endpoint snapshot", "endpoint_eui", endpoint.EUI.String(), "blueprint_id", bp.ID, "version", bp.Version) return bp, nil @@ -113,14 +113,14 @@ func (s *ResolverService) ResolveBlueprintForEndpoint( // Get the default blueprint for this model bp, err := s.blueprintRepo.GetDefaultForModel(ctx, tenantID, modelID) if err != nil { - s.log.Debug("Error getting default blueprint for model", + s.log.DebugContext(ctx, "Error getting default blueprint for model", "device_model_id", *endpoint.DeviceModelID, "error", err) return nil, nil } if bp != nil { - s.log.Debug("Resolved blueprint from device model", + s.log.DebugContext(ctx, "Resolved blueprint from device model", "endpoint_eui", endpoint.EUI.String(), "device_model_id", *endpoint.DeviceModelID, "blueprint_id", bp.ID) @@ -128,7 +128,7 @@ func (s *ResolverService) ResolveBlueprintForEndpoint( } // Model has no default blueprint - return nil (decode will be skipped) - s.log.Debug("Device model has no default blueprint", + s.log.DebugContext(ctx, "Device model has no default blueprint", "endpoint_eui", endpoint.EUI.String(), "device_model_id", *endpoint.DeviceModelID) return nil, nil @@ -140,7 +140,7 @@ func (s *ResolverService) ResolveBlueprintForEndpoint( return s.ResolveBlueprint(ctx, tenantID, typeEUIBytes, formatID) } - s.log.Debug("Endpoint has no Type EUI or device model", + s.log.DebugContext(ctx, "Endpoint has no Type EUI or device model", "endpoint_eui", endpoint.EUI.String()) return nil, nil } @@ -148,7 +148,7 @@ func (s *ResolverService) ResolveBlueprintForEndpoint( // GetEndpointCalibration retrieves calibration data for an endpoint. // Returns an empty map if no calibration data is available. func (s *ResolverService) GetEndpointCalibration( - _ context.Context, // ctx reserved for future async operations + ctx context.Context, _ int64, // tenantID reserved for future multi-tenant calibration stores endpoint *models.EndPoint, ) map[string]interface{} { @@ -159,7 +159,7 @@ func (s *ResolverService) GetEndpointCalibration( // Parse JSON calibration data var calibration map[string]interface{} if err := parseJSON(endpoint.CalibrationData, &calibration); err != nil { - s.log.Warn("Failed to parse endpoint calibration data", + s.log.WarnContext(ctx, "Failed to parse endpoint calibration data", "endpoint_eui", endpoint.EUI.String(), "error", err) return make(map[string]interface{}) diff --git a/KC-Core/internal/services/bssci/blueprint/resolver_test.go b/KC-Core/internal/services/bssci/blueprint/resolver_test.go index 209c1aa..bfbecc7 100644 --- a/KC-Core/internal/services/bssci/blueprint/resolver_test.go +++ b/KC-Core/internal/services/bssci/blueprint/resolver_test.go @@ -11,6 +11,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // stubBlueprintRepo overrides only the methods a test exercises; any other call panics. @@ -65,7 +67,7 @@ func TestResolveBlueprintForEndpoint_SnapshotFirst(t *testing.T) { } svc := NewResolverService(logger.NewNop(), repo, nil, nil) - bp, err := svc.ResolveBlueprintForEndpoint(context.Background(), 1, ep, nil) + bp, err := svc.ResolveBlueprintForEndpoint(testutil.TestContext(), 1, ep, nil) require.NoError(t, err) require.NotNil(t, bp) assert.Equal(t, tt.wantID, bp.ID) diff --git a/KC-Core/internal/services/bssci/cert_identity_test.go b/KC-Core/internal/services/bssci/cert_identity_test.go index 1baeb43..1da8841 100644 --- a/KC-Core/internal/services/bssci/cert_identity_test.go +++ b/KC-Core/internal/services/bssci/cert_identity_test.go @@ -15,6 +15,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // makeTestCert creates a real self-signed x509 certificate with the given CN. @@ -79,7 +81,7 @@ func TestCertIdentity_EUICN_ResolvesRegisteredStation(t *testing.T) { } resolver := NewCertificateIdentityResolver(repo, orgResolver, &mockLoggerForDispatch{}) - identity, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "CA-FE-CA-FE-CA-FE-CA-FE")) + identity, err := resolver.ResolveCertificateIdentity(testutil.TestContext(), makeTestCert(t, "CA-FE-CA-FE-CA-FE-CA-FE")) require.NoError(t, err) assert.Equal(t, int64(42), identity.TenantID) @@ -95,7 +97,7 @@ func TestCertIdentity_EUICN_UnregisteredStationRejected(t *testing.T) { repo := &certIdentityBSRepo{getErr: errors.New("not found")} resolver := NewCertificateIdentityResolver(repo, &certIdentityOrgResolver{}, &mockLoggerForDispatch{}) - _, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "CA-FE-CA-FE-CA-FE-CA-FE")) + _, err := resolver.ResolveCertificateIdentity(testutil.TestContext(), makeTestCert(t, "CA-FE-CA-FE-CA-FE-CA-FE")) require.Error(t, err, "an unregistered EUI CN must not resolve") } @@ -108,7 +110,7 @@ func TestCertIdentity_OrgCN_DelegatesToOrgResolver(t *testing.T) { orgResolver := &certIdentityOrgResolver{resolveCertOrg: orgID, resolveCertTenant: 9} resolver := NewCertificateIdentityResolver(&mockBaseStationRepo{}, orgResolver, &mockLoggerForDispatch{}) - identity, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "org-"+orgID.String())) + identity, err := resolver.ResolveCertificateIdentity(testutil.TestContext(), makeTestCert(t, "org-"+orgID.String())) require.NoError(t, err) assert.Equal(t, 1, orgResolver.resolveCertCalls, "org CNs delegate to ResolveCert") @@ -123,7 +125,7 @@ func TestCertIdentity_OrgCN_DelegateFailurePropagates(t *testing.T) { orgResolver := &certIdentityOrgResolver{resolveCertErr: errors.New("unknown org")} resolver := NewCertificateIdentityResolver(&mockBaseStationRepo{}, orgResolver, &mockLoggerForDispatch{}) - _, err := resolver.ResolveCertificateIdentity(context.Background(), makeTestCert(t, "org-unknown")) + _, err := resolver.ResolveCertificateIdentity(testutil.TestContext(), makeTestCert(t, "org-unknown")) require.Error(t, err) } diff --git a/KC-Core/internal/services/bssci/connection_service.go b/KC-Core/internal/services/bssci/connection_service.go index 83258d3..5f8e5a7 100644 --- a/KC-Core/internal/services/bssci/connection_service.go +++ b/KC-Core/internal/services/bssci/connection_service.go @@ -4,43 +4,38 @@ import ( "context" "time" + "encoding/binary" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + pkgmioty "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" ) -type connectionService struct { - logger logger.Logger -} - -// NewConnectionService creates a new connection service -func NewConnectionService(log logger.Logger) bssci.ConnectionService { - return &connectionService{ - logger: log, - } +// connectionRegistry adapts basestation.ConnectionManager to the narrow +// bssci.BaseStationConnectionRegistry contract the connect flow consumes. +type connectionRegistry struct { + manager *basestation.ConnectionManager + logger logger.Logger } -// GetBaseStation retrieves basestation via ConnectionManager (tenant-scoped via context) -func (c *connectionService) GetBaseStation(ctx context.Context, eui [8]byte, mgr *basestation.ConnectionManager) (*basestation.BaseStation, error) { - bs, err := mgr.GetBaseStation(ctx, eui) - if err != nil || bs == nil { - c.logger.Error(bssci.LogBSSCIBaseStationNotFoundInDatabase, - "euiHex", formatEUI(eui), - "error", err) - return nil, bssci.NewCatalogError(bssci.ErrBaseStationNotRegistered, bssci.POSIX_EPERM) +// NewConnectionRegistry captures the concrete connection manager so callers +// depend only on the registry contract. +func NewConnectionRegistry(manager *basestation.ConnectionManager, log logger.Logger) bssci.BaseStationConnectionRegistry { + return &connectionRegistry{ + manager: manager, + logger: log, } - - return bs, nil } -// GetBaseStationGlobal retrieves basestation by EUI across all tenants. -// Used during BSSCI connect handshake before tenant is resolved. -func (c *connectionService) GetBaseStationGlobal(ctx context.Context, eui [8]byte, mgr *basestation.ConnectionManager) (*basestation.BaseStation, error) { - bs, err := mgr.GetBaseStationGlobal(ctx, eui) +// GetBaseStationGlobal retrieves a base station by EUI across all tenants. +// Used during the BSSCI connect handshake before the tenant is resolved. +func (c *connectionRegistry) GetBaseStationGlobal(ctx context.Context, eui [8]byte) (*basestation.BaseStation, error) { + bs, err := c.manager.GetBaseStationGlobal(ctx, eui) if err != nil || bs == nil { - c.logger.Error(bssci.LogBSSCIBaseStationNotFoundInDatabase, - "euiHex", formatEUI(eui), + c.logger.ErrorContext(ctx, bssci.LogBSSCIBaseStationNotFoundInDatabase, + "euiHex", pkgmioty.FormatEUI64(binary.BigEndian.Uint64(eui[:])), "error", err) return nil, bssci.NewCatalogError(bssci.ErrBaseStationNotRegistered, bssci.POSIX_EPERM) } @@ -48,9 +43,9 @@ func (c *connectionService) GetBaseStationGlobal(ctx context.Context, eui [8]byt return bs, nil } -// RegisterConnection updates basestation connection status via REAL ConnectionManager -func (c *connectionService) RegisterConnection(ctx context.Context, session *bssci.Session, _ *basestation.BaseStation, mgr *basestation.ConnectionManager) error { - // Build connection status matching the real ConnectionStatus struct +// RegisterConnection publishes the session's live connection and marks the +// base station online. +func (c *connectionRegistry) RegisterConnection(ctx context.Context, session *bssci.Session, _ *basestation.BaseStation) error { status := &basestation.ConnectionStatus{ IsOnline: true, LastSeen: time.Now(), @@ -58,12 +53,10 @@ func (c *connectionService) RegisterConnection(ctx context.Context, session *bss SessionID: session.ID, } - // Convert EUI to [8]byte for UpdateConnectionStatus euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() - err := mgr.UpdateConnectionStatus(ctx, euiBytes, status) - if err != nil { - c.logger.Error("Failed to update basestation connection status", + if err := c.manager.UpdateConnectionStatus(ctx, euiBytes, status); err != nil { + c.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateConnectionStatus, "error", err, "bsEui", session.BaseStationEUI) return err @@ -71,24 +64,3 @@ func (c *connectionService) RegisterConnection(ctx context.Context, session *bss return nil } - -// formatEUI formats an 8-byte EUI as a hex string -func formatEUI(eui [8]byte) string { - return string([]byte{ - hexDigit(eui[0] >> 4), hexDigit(eui[0] & 0x0F), - hexDigit(eui[1] >> 4), hexDigit(eui[1] & 0x0F), - hexDigit(eui[2] >> 4), hexDigit(eui[2] & 0x0F), - hexDigit(eui[3] >> 4), hexDigit(eui[3] & 0x0F), - hexDigit(eui[4] >> 4), hexDigit(eui[4] & 0x0F), - hexDigit(eui[5] >> 4), hexDigit(eui[5] & 0x0F), - hexDigit(eui[6] >> 4), hexDigit(eui[6] & 0x0F), - hexDigit(eui[7] >> 4), hexDigit(eui[7] & 0x0F), - }) -} - -func hexDigit(n byte) byte { - if n < 10 { - return '0' + n - } - return 'A' + (n - 10) -} diff --git a/KC-Core/internal/services/bssci/downlink_dispatcher_test.go b/KC-Core/internal/services/bssci/downlink_dispatcher_test.go index 57c67a3..6f4bf8f 100644 --- a/KC-Core/internal/services/bssci/downlink_dispatcher_test.go +++ b/KC-Core/internal/services/bssci/downlink_dispatcher_test.go @@ -13,6 +13,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/google/uuid" "github.com/jmoiron/sqlx" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockLoggerForDispatch is a minimal mock implementing logger.Logger @@ -288,7 +290,7 @@ func TestDispatchIfAvailable_Success(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) if err != nil { @@ -322,7 +324,7 @@ func TestDispatchIfAvailable_DlRxStatQryPassthrough(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, false, false) if err != nil || !dispatched { t.Fatalf("dlRxStatQry=%v: unexpected result dispatched=%v err=%v", want, dispatched, err) @@ -338,7 +340,7 @@ func TestDispatchIfAvailable_NoPendingDownlinks(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) if err != nil { @@ -361,7 +363,7 @@ func TestDispatchIfAvailable_NoTenantContext(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 0, uuid.New(), testDispatchSession(), + testutil.TestContext(), 0, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) if err != nil { @@ -381,7 +383,7 @@ func TestDispatchIfAvailable_TransactionBeginError(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) // Should gracefully degrade, not return error (don't fail uplink) @@ -401,7 +403,7 @@ func TestDispatchIfAvailable_ReservationCommitError(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) if err != nil { @@ -424,7 +426,7 @@ func TestDispatchIfAvailable_SendFunctionError_ReleasesToPending(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) if err == nil { @@ -454,7 +456,7 @@ func TestDispatchIfAvailable_AmbiguousSendError_StaysReserved(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) if !errors.Is(err, bssci.ErrAmbiguousWrite) { @@ -481,7 +483,7 @@ func TestDispatchIfAvailable_MarkQueuedError_ReportsDispatched(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) // The send happened: the dispatch is reported and the row stays reserved @@ -515,7 +517,7 @@ func TestDispatchIfAvailable_UsesUserDataIfPresent(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), 42, uuid.New(), testDispatchSession(), + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 0xAABBCCDDEEFF0011, true, false) if err != nil { @@ -541,7 +543,7 @@ func TestDispatchQueue_Success(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchQueue( - context.Background(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -570,7 +572,7 @@ func TestDispatchQueue_NoMatchingPendingRow(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchQueue( - context.Background(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -592,7 +594,7 @@ func TestDispatchQueue_ReservationError(t *testing.T) { dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchQueue( - context.Background(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), 777, 0xAABBCCDDEEFF0011) if err == nil { t.Fatal("expected reservation error to propagate") diff --git a/KC-Core/internal/services/bssci/factories.go b/KC-Core/internal/services/bssci/factories.go index 57af1c1..99e8341 100644 --- a/KC-Core/internal/services/bssci/factories.go +++ b/KC-Core/internal/services/bssci/factories.go @@ -17,7 +17,7 @@ type BSSCIServiceBundle struct { VersionNegotiator bssci.VersionNegotiator DownlinkSvc bssci.DownlinkService StatusSvc bssci.StatusService - ConnectionSvc bssci.ConnectionService + ConnectionSvc bssci.BaseStationConnectionRegistry Broadcaster bssci.SCACIBroadcaster // Initially unwired - set via SetSCACIServer EPStatusBroadcaster bssci.SCACIEPStatusBroadcaster // EPStatus adapter - set via SetSCACIEPStatusServer QueueSerializer bssci.QueueSerializer @@ -45,6 +45,7 @@ func NewBSSCIServices( storage interfaces.Storage, systemEventStore interfaces.SystemEventStore, queueStore *postgres.DownlinkQueueReader, + connectionMgr *basestation.ConnectionManager, log logger.Logger, tenantID int64, orgResolver org.Resolver, @@ -68,7 +69,7 @@ func NewBSSCIServices( ) // StatusService uses PendingOperationRepository statusSvc := NewStatusService(pendingOps, pendingOpsMu, storage.PendingOperations(), log) - connectionSvc := NewConnectionService(log) + connectionSvc := NewConnectionRegistry(connectionMgr, log) queueSerializer := NewQueueSerializer() auditLogger := NewAuditLogger(systemEventStore) tenantResolver := NewTenantResolver(queueStore) diff --git a/KC-Core/internal/services/bssci/mqtt_adapter_test.go b/KC-Core/internal/services/bssci/mqtt_adapter_test.go index 1d3d4a1..5d882f3 100644 --- a/KC-Core/internal/services/bssci/mqtt_adapter_test.go +++ b/KC-Core/internal/services/bssci/mqtt_adapter_test.go @@ -10,6 +10,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-MQTT/pkg/mqtt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockDeviceEventPublisher records PublishDeviceEvent calls. @@ -58,7 +60,7 @@ func TestMQTTAdapter_PublishUplink_CorrectPayloadAndTopic(t *testing.T) { mock := &mockDeviceEventPublisher{} adapter := NewMQTTAdapter(mock) - err := adapter.PublishUplink(context.Background(), "org-uuid-123", + err := adapter.PublishUplink(testutil.TestContext(), "org-uuid-123", 0x70B3D59CD00009E6, 0xABCDEF1234567890, -80.5, 15.2, 1700000000000000000, 42, []byte("hello"), nil) @@ -85,7 +87,7 @@ func TestMQTTAdapter_PublishUplink_ZeroPaddedEUI(t *testing.T) { mock := &mockDeviceEventPublisher{} adapter := NewMQTTAdapter(mock) - err := adapter.PublishUplink(context.Background(), "org", 0x00000001, 0x00000002, + err := adapter.PublishUplink(testutil.TestContext(), "org", 0x00000001, 0x00000002, 0, 0, 0, 0, nil, nil) require.NoError(t, err) @@ -103,7 +105,7 @@ func TestMQTTAdapter_PublishAttach_CorrectPayload(t *testing.T) { mock := &mockDeviceEventPublisher{} adapter := NewMQTTAdapter(mock) - err := adapter.PublishAttach(context.Background(), "org-uuid", + err := adapter.PublishAttach(testutil.TestContext(), "org-uuid", 0x70B3D59CD00009E6, 0xABCDEF1234567890) require.NoError(t, err) @@ -125,7 +127,7 @@ func TestMQTTAdapter_PublishDetach_CorrectPayload(t *testing.T) { mock := &mockDeviceEventPublisher{} adapter := NewMQTTAdapter(mock) - err := adapter.PublishDetach(context.Background(), "org-uuid", + err := adapter.PublishDetach(testutil.TestContext(), "org-uuid", 0x70B3D59CD00009E6, 0xABCDEF1234567890) require.NoError(t, err) @@ -145,7 +147,7 @@ func TestMQTTAdapter_PublishDownlinkResult_CorrectPayload(t *testing.T) { mock := &mockDeviceEventPublisher{} adapter := NewMQTTAdapter(mock) - err := adapter.PublishDownlinkResult(context.Background(), "org-uuid", + err := adapter.PublishDownlinkResult(testutil.TestContext(), "org-uuid", 0x70B3D59CD00009E6, 12345, "success") require.NoError(t, err) @@ -165,7 +167,7 @@ func TestMQTTAdapter_PropagatesPublishError(t *testing.T) { mock := &mockDeviceEventPublisher{returnErr: fmt.Errorf("broker down")} adapter := NewMQTTAdapter(mock) - err := adapter.PublishUplink(context.Background(), "org", 1, 2, 0, 0, 0, 0, nil, nil) + err := adapter.PublishUplink(testutil.TestContext(), "org", 1, 2, 0, 0, 0, 0, nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "broker down") } diff --git a/KC-Core/internal/services/bssci/mqtt_downlink_adapter_test.go b/KC-Core/internal/services/bssci/mqtt_downlink_adapter_test.go index 6f7334f..f701a98 100644 --- a/KC-Core/internal/services/bssci/mqtt_downlink_adapter_test.go +++ b/KC-Core/internal/services/bssci/mqtt_downlink_adapter_test.go @@ -13,6 +13,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockDownlinkQueuer implements DownlinkQueuer for adapter tests. @@ -69,7 +71,7 @@ func TestMQTTDownlinkAdapter_GeneratesNonZeroQueID(t *testing.T) { adapter := NewMQTTDownlinkAdapter(mock) orgID := uuid.New() - _, err := adapter.EnqueueFromMQTT(context.Background(), 1, &orgID, 0x1234, []byte("test"), false) + _, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 1, &orgID, 0x1234, []byte("test"), false) require.NoError(t, err) call := mock.lastCall() @@ -85,7 +87,7 @@ func TestMQTTDownlinkAdapter_GeneratesUniqueQueIDsAcrossRapidCalls(t *testing.T) const calls = 64 for i := 0; i < calls; i++ { - _, err := adapter.EnqueueFromMQTT(context.Background(), 1, &orgID, 0x1234, []byte("test"), false) + _, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 1, &orgID, 0x1234, []byte("test"), false) require.NoError(t, err) } @@ -107,7 +109,7 @@ func TestMQTTDownlinkAdapter_WrapsPayloadAsSliceOfByteSlices(t *testing.T) { orgID := uuid.New() payload := []byte("hello world") - _, err := adapter.EnqueueFromMQTT(context.Background(), 1, &orgID, 0x1234, payload, false) + _, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 1, &orgID, 0x1234, payload, false) require.NoError(t, err) call := mock.lastCall() @@ -121,7 +123,7 @@ func TestMQTTDownlinkAdapter_ConfirmedTrue_SetsResponseExpTrue(t *testing.T) { adapter := NewMQTTDownlinkAdapter(mock) orgID := uuid.New() - _, err := adapter.EnqueueFromMQTT(context.Background(), 1, &orgID, 0x1234, []byte("test"), true) + _, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 1, &orgID, 0x1234, []byte("test"), true) require.NoError(t, err) call := mock.lastCall() @@ -135,7 +137,7 @@ func TestMQTTDownlinkAdapter_ConfirmedFalse_SetsResponseExpFalse(t *testing.T) { adapter := NewMQTTDownlinkAdapter(mock) orgID := uuid.New() - _, err := adapter.EnqueueFromMQTT(context.Background(), 1, &orgID, 0x1234, []byte("test"), false) + _, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 1, &orgID, 0x1234, []byte("test"), false) require.NoError(t, err) call := mock.lastCall() @@ -149,7 +151,7 @@ func TestMQTTDownlinkAdapter_ReturnsResultQueID(t *testing.T) { adapter := NewMQTTDownlinkAdapter(mock) orgID := uuid.New() - queID, err := adapter.EnqueueFromMQTT(context.Background(), 1, &orgID, 0x1234, []byte("test"), false) + queID, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 1, &orgID, 0x1234, []byte("test"), false) require.NoError(t, err) assert.Equal(t, uint64(99999), queID) } @@ -160,7 +162,7 @@ func TestMQTTDownlinkAdapter_PropagatesQueueError(t *testing.T) { adapter := NewMQTTDownlinkAdapter(mock) orgID := uuid.New() - queID, err := adapter.EnqueueFromMQTT(context.Background(), 1, &orgID, 0x1234, []byte("test"), false) + queID, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 1, &orgID, 0x1234, []byte("test"), false) require.Error(t, err) assert.Contains(t, err.Error(), "queue full") assert.Equal(t, uint64(0), queID) @@ -173,7 +175,7 @@ func TestMQTTDownlinkAdapter_PassesCorrectEpEUI(t *testing.T) { orgID := uuid.New() epEUI := uint64(0x70B3D59CD00009E6) - _, err := adapter.EnqueueFromMQTT(context.Background(), 42, &orgID, epEUI, []byte("test"), false) + _, err := adapter.EnqueueFromMQTT(testutil.TestContext(), 42, &orgID, epEUI, []byte("test"), false) require.NoError(t, err) call := mock.lastCall() @@ -202,7 +204,7 @@ func TestSCACIDownlinkQueuer_SuccessReturnsQueID(t *testing.T) { returnResult: &scaci.DLDataQueueResult{QueID: 42}, } queuer := NewSCACIDownlinkQueuer(mock) - id, err := queuer.QueueDownlink(context.Background(), 1, nil, &mioty.DLDataQueue{}) + id, err := queuer.QueueDownlink(testutil.TestContext(), 1, nil, &mioty.DLDataQueue{}) require.NoError(t, err) assert.Equal(t, uint64(42), id) } @@ -213,7 +215,7 @@ func TestSCACIDownlinkQueuer_ErrorPropagates(t *testing.T) { returnErr: fmt.Errorf("scaci unavailable"), } queuer := NewSCACIDownlinkQueuer(mock) - id, err := queuer.QueueDownlink(context.Background(), 1, nil, &mioty.DLDataQueue{}) + id, err := queuer.QueueDownlink(testutil.TestContext(), 1, nil, &mioty.DLDataQueue{}) assert.Error(t, err) assert.Equal(t, uint64(0), id) } @@ -225,7 +227,7 @@ func TestSCACIDownlinkQueuer_NilResultReturnsError(t *testing.T) { returnErr: nil, } queuer := NewSCACIDownlinkQueuer(mock) - id, err := queuer.QueueDownlink(context.Background(), 1, nil, &mioty.DLDataQueue{}) + id, err := queuer.QueueDownlink(testutil.TestContext(), 1, nil, &mioty.DLDataQueue{}) assert.Error(t, err) var catErr *bssci.CatalogError require.ErrorAs(t, err, &catErr) diff --git a/KC-Core/internal/services/bssci/propagation_service_test.go b/KC-Core/internal/services/bssci/propagation_service_test.go index 92dbeb4..e573da5 100644 --- a/KC-Core/internal/services/bssci/propagation_service_test.go +++ b/KC-Core/internal/services/bssci/propagation_service_test.go @@ -14,6 +14,8 @@ import ( pkgcontext "github.com/Kiloiot/kilo-service-center/pkg/context" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestTenantFilteringInTriggerEndpointPropagate verifies ATT-02: endpoint propagation @@ -50,7 +52,7 @@ func TestTenantFilteringInTriggerEndpointPropagate(t *testing.T) { } // ATT-02: Add tenant context (required by propagation service) - ctx := pkgcontext.WithTenantID(context.Background(), endpointTenant) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), endpointTenant) err := env.service.TriggerEndpointPropagate(ctx, endpoint.ID, sessions) require.NoError(t, err, "propagation should succeed") @@ -107,7 +109,7 @@ func TestPropagateStatusFilteringInReconcile(t *testing.T) { TenantID: tenantID, } - ctx := context.Background() + ctx := testutil.TestContext() err := env.service.ReconcileBaseStation(ctx, session, nil) require.NoError(t, err, "reconciliation should succeed") @@ -145,7 +147,7 @@ func TestRoamingPolicyEnforcement(t *testing.T) { } // ATT-03: Add endpoint tenant context - ctx := pkgcontext.WithTenantID(context.Background(), endpointTenant) + ctx := pkgcontext.WithTenantID(testutil.TestContext(), endpointTenant) err := env.service.TriggerEndpointPropagate(ctx, endpoint.ID, sessions) // ATT-03: Should succeed but not send any propagates (blocked by roaming policy) @@ -175,7 +177,7 @@ func TestUnidirectionalEndpointPropagated(t *testing.T) { TenantID: tenantID, } - ctx := context.Background() + ctx := testutil.TestContext() err := env.service.ReconcileBaseStation(ctx, session, nil) require.NoError(t, err, "reconciliation should succeed") @@ -214,7 +216,7 @@ func TestMultiBSPropagation(t *testing.T) { TenantID: tenantID, } - ctx := context.Background() + ctx := testutil.TestContext() err := env.service.ReconcileBaseStation(ctx, sessionBSB, nil) require.NoError(t, err, "reconciliation should succeed") diff --git a/KC-Core/internal/services/bssci/roaming_integration_test.go b/KC-Core/internal/services/bssci/roaming_integration_test.go index 0decbf3..5363ea2 100644 --- a/KC-Core/internal/services/bssci/roaming_integration_test.go +++ b/KC-Core/internal/services/bssci/roaming_integration_test.go @@ -16,6 +16,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // Integration test for complete roaming flow @@ -27,7 +29,7 @@ func TestRoamingIntegration_AttachDetachFlow(t *testing.T) { // 4. Detach cleans up roaming state // Setup test environment - ctx := context.Background() + ctx := testutil.TestContext() // Create test data epEui := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} @@ -133,7 +135,7 @@ func TestRoamingIntegration_AttachDetachFlow(t *testing.T) { // Test roaming validation with invalid partnerships func TestRoamingIntegration_InvalidPartnership(t *testing.T) { - ctx := context.Background() + ctx := testutil.TestContext() epEui := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22} ownerTenantID := int64(5) @@ -181,7 +183,7 @@ func TestRoamingIntegration_InvalidPartnership(t *testing.T) { // Test cache expiration and refresh func TestRoamingIntegration_CacheExpiration(t *testing.T) { - ctx := context.Background() + ctx := testutil.TestContext() epEui := []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88} ownerTenantID := int64(3) @@ -238,7 +240,7 @@ func TestRoamingIntegration_CacheExpiration(t *testing.T) { // Test metrics collection func TestRoamingIntegration_Metrics(t *testing.T) { - ctx := context.Background() + ctx := testutil.TestContext() mockStorage := &MockStorageWithRoaming{ endpoints: map[string]*models.EndPoint{ diff --git a/KC-Core/internal/services/bssci/scaci_forwarder_test.go b/KC-Core/internal/services/bssci/scaci_forwarder_test.go index c88b342..63ca4f0 100644 --- a/KC-Core/internal/services/bssci/scaci_forwarder_test.go +++ b/KC-Core/internal/services/bssci/scaci_forwarder_test.go @@ -10,6 +10,8 @@ import ( pkgmioty "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/mioty" // EPStatus constants "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/scaci" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockSCACIEPStatusBroadcaster implements scaciEPStatusBroadcaster for testing @@ -65,7 +67,7 @@ func TestSCACIEPStatusAdapter_SetSCACIServer_Stores(t *testing.T) { EpStatus: pkgmioty.EPStatusAttached, } - err := adapter.BroadcastEPStatus(context.Background(), 42, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 42, data) if err != nil { t.Fatalf("BroadcastEPStatus failed: %v", err) } @@ -90,7 +92,7 @@ func TestSCACIEPStatusAdapter_SetSCACIServer_NilDoesNotPanic(t *testing.T) { EpStatus: pkgmioty.EPStatusAttached, } - err := adapter.BroadcastEPStatus(context.Background(), 42, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 42, data) if err != nil { t.Fatalf("BroadcastEPStatus should return nil when server is nil, got: %v", err) } @@ -106,7 +108,7 @@ func TestSCACIEPStatusAdapter_BroadcastEPStatus_NoServer_ReturnsNil(t *testing.T } // Should return nil (not an error, just no-op) - err := adapter.BroadcastEPStatus(context.Background(), 100, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 100, data) if err != nil { t.Fatalf("expected nil error when no server, got: %v", err) } @@ -118,7 +120,7 @@ func TestSCACIEPStatusAdapter_BroadcastEPStatus_NilData_ReturnsNil(t *testing.T) adapter.SetSCACIServer(mock) // Nil data should return nil without calling the server - err := adapter.BroadcastEPStatus(context.Background(), 42, nil) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 42, nil) if err != nil { t.Fatalf("expected nil error for nil data, got: %v", err) } @@ -159,7 +161,7 @@ func TestSCACIEPStatusAdapter_BroadcastEPStatus_ConvertsTypes(t *testing.T) { Subpackets: subpackets, } - err := adapter.BroadcastEPStatus(context.Background(), 123, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 123, data) if err != nil { t.Fatalf("BroadcastEPStatus failed: %v", err) } @@ -213,7 +215,7 @@ func TestSCACIEPStatusAdapter_BroadcastEPStatus_PropagatesError(t *testing.T) { EpStatus: pkgmioty.EPStatusAttached, } - err := adapter.BroadcastEPStatus(context.Background(), 42, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 42, data) if err == nil { t.Fatal("expected error to be propagated") } @@ -234,7 +236,7 @@ func TestSCACIEPStatusAdapter_SetSCACIServer_InvalidType_Ignored(t *testing.T) { EpStatus: pkgmioty.EPStatusAttached, } - err := adapter.BroadcastEPStatus(context.Background(), 42, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 42, data) if err != nil { t.Fatalf("expected nil error when invalid server type, got: %v", err) } @@ -250,7 +252,7 @@ func TestSCACIEPStatusAdapter_BroadcastEPStatus_AttachedStatus(t *testing.T) { EpStatus: pkgmioty.EPStatusAttached, } - err := adapter.BroadcastEPStatus(context.Background(), 42, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 42, data) if err != nil { t.Fatalf("BroadcastEPStatus failed: %v", err) } @@ -270,7 +272,7 @@ func TestSCACIEPStatusAdapter_BroadcastEPStatus_DetachedStatus(t *testing.T) { EpStatus: pkgmioty.EPStatusDetached, } - err := adapter.BroadcastEPStatus(context.Background(), 42, data) + err := adapter.BroadcastEPStatus(testutil.TestContext(), 42, data) if err != nil { t.Fatalf("BroadcastEPStatus failed: %v", err) } diff --git a/KC-Core/internal/services/bssci/session_resume_db_test.go b/KC-Core/internal/services/bssci/session_resume_db_test.go index 3278d90..0b9f467 100644 --- a/KC-Core/internal/services/bssci/session_resume_db_test.go +++ b/KC-Core/internal/services/bssci/session_resume_db_test.go @@ -1,7 +1,6 @@ package bssciservices import ( - "context" "errors" "testing" "time" @@ -14,6 +13,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) func newResumeTestService(t *testing.T, tenantID int64) (*sessionService, *mockBaseStationSessionRepo) { @@ -68,7 +69,7 @@ func TestHandleResume_DBLookup_BySnBsUuid(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], int64Ptr(1000), int64Ptr(-500), 0x123456789ABCDEF0) require.Equal(t, bssci.ResumeCompatible, outcome.Disposition) @@ -99,7 +100,7 @@ func TestHandleResume_AbsentCounters(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0xAABB) + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], nil, nil, 0xAABB) require.Equal(t, bssci.ResumeCompatible, outcome.Disposition) require.NotNil(t, outcome.Previous) @@ -123,7 +124,7 @@ func TestHandleResume_StaleScCounterAccepted(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], int64Ptr(400), int64Ptr(-700), 0xCCDD) require.Equal(t, bssci.ResumeCompatible, outcome.Disposition) @@ -147,7 +148,7 @@ func TestHandleResume_RequiredBsOpIdBeyondPersisted(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], int64Ptr(1001), int64Ptr(-500), 0xEEFF) assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) @@ -168,7 +169,7 @@ func TestHandleResume_ClaimedScOpIdBeyondIssued(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], int64Ptr(1000), int64Ptr(-501), 0xEEFF) assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) @@ -190,7 +191,7 @@ func TestHandleResume_TerminatedNotResumable(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], nil, nil, 0x1234) assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition, "terminated sessions must not resume") } @@ -209,7 +210,7 @@ func TestHandleResume_ActiveNotResumableFromDB(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], nil, nil, 0x1234) assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition, "only disconnected sessions are resumable from persistence") } @@ -224,7 +225,7 @@ func TestHandleResume_ShortUUID_SkipsDBLookup(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, []byte{0x01, 0x02}, + outcome := svc.HandleResume(testutil.TestContext(), testSession, []byte{0x01, 0x02}, int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) @@ -240,7 +241,7 @@ func TestHandleResume_NilUUID_SkipsDBLookup(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, nil, + outcome := svc.HandleResume(testutil.TestContext(), testSession, nil, int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) @@ -257,7 +258,7 @@ func TestHandleResume_NoDBMatch_ReturnNil(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, unknownUUID[:], + outcome := svc.HandleResume(testutil.TestContext(), testSession, unknownUUID[:], int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) @@ -276,7 +277,7 @@ func TestHandleResume_TenantIsolation(t *testing.T) { ResolvedTenantID: 100, }, } - wrongOutcome := svc.HandleResume(context.Background(), wrongTenantSession, bsUUID[:], + wrongOutcome := svc.HandleResume(testutil.TestContext(), wrongTenantSession, bsUUID[:], int64Ptr(5000), int64Ptr(-2500), 0x1122334455667788) assert.Equal(t, bssci.ResumeNoMatch, wrongOutcome.Disposition, "cross-tenant UUID collisions must not resume") @@ -285,7 +286,7 @@ func TestHandleResume_TenantIsolation(t *testing.T) { ResolvedTenantID: 500, }, } - correctOutcome := svc.HandleResume(context.Background(), correctTenantSession, bsUUID[:], + correctOutcome := svc.HandleResume(testutil.TestContext(), correctTenantSession, bsUUID[:], int64Ptr(5000), int64Ptr(-2500), 0x1122334455667788) require.Equal(t, bssci.ResumeCompatible, correctOutcome.Disposition) require.NotNil(t, correctOutcome.Previous) @@ -374,7 +375,7 @@ func TestPersistSessionResumeUpdatesProtocolVersion(t *testing.T) { }, } - err := svc.PersistSession(context.Background(), session, nil, true, nil) + err := svc.PersistSession(testutil.TestContext(), session, nil, true, nil) require.NoError(t, err) // Verify repository row now has protocol_version populated and the resume @@ -414,7 +415,7 @@ func TestMarkDisconnected_StaleConnectionDoesNotTouchNewerSession(t *testing.T) Encoding: "msgpack", }, } - require.NoError(t, svc.PersistSession(context.Background(), sessionB, nil, true, nil)) + require.NoError(t, svc.PersistSession(testutil.TestContext(), sessionB, nil, true, nil)) require.Equal(t, models.SessionStatusActive, dbSession.Status) // Connection A's deferred cleanup runs after B took over @@ -425,7 +426,7 @@ func TestMarkDisconnected_StaleConnectionDoesNotTouchNewerSession(t *testing.T) DbSessionID: 77, }, } - require.NoError(t, svc.MarkDisconnected(context.Background(), sessionA)) + require.NoError(t, svc.MarkDisconnected(testutil.TestContext(), sessionA)) // B remains active and resumable state is untouched by A's cleanup assert.Equal(t, models.SessionStatusActive, dbSession.Status, @@ -433,7 +434,7 @@ func TestMarkDisconnected_StaleConnectionDoesNotTouchNewerSession(t *testing.T) assert.Nil(t, dbSession.EndedAt) // B's own later cleanup does transition the session - require.NoError(t, svc.MarkDisconnected(context.Background(), sessionB)) + require.NoError(t, svc.MarkDisconnected(testutil.TestContext(), sessionB)) assert.Equal(t, models.SessionStatusDisconnected, dbSession.Status) assert.True(t, dbSession.CanResume) assert.NotNil(t, dbSession.EndedAt) @@ -452,7 +453,7 @@ func TestHandleResume_InfrastructureFailure(t *testing.T) { ResolvedTenantID: 100, }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], nil, nil, 0x1234) assert.Equal(t, bssci.ResumeInfrastructureFailure, outcome.Disposition) require.Error(t, outcome.Err) @@ -476,7 +477,7 @@ func TestHandleResume_VersionIncompatible(t *testing.T) { NegotiatedVersion: "1.0.0", }, } - outcome := svc.HandleResume(context.Background(), testSession, bsUUID[:], nil, nil, 0x1234) + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], nil, nil, 0x1234) assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) require.NotNil(t, outcome.Previous, "the stale session is returned for termination") diff --git a/KC-Core/internal/services/bssci/session_service.go b/KC-Core/internal/services/bssci/session_service.go index d226354..d6735c6 100644 --- a/KC-Core/internal/services/bssci/session_service.go +++ b/KC-Core/internal/services/bssci/session_service.go @@ -106,7 +106,7 @@ func (s *sessionService) HandleResume(ctx context.Context, session *bssci.Sessio // with the version selected for this connection (BSSCI rev1 §4.3: patch // differences are compatible; major/minor must match). if !resumeVersionCompatible(restoredSession.NegotiatedVersion, session.NegotiatedVersion) { - s.logger.WarnContext(ctx, "Resume rejected: persisted negotiated version incompatible with the selected version", + s.logger.WarnContext(ctx, bssci.LogBSSCIResumeRejectedVersionIncompatible, "bsEui", bsEUI, "persistedVersion", restoredSession.NegotiatedVersion, "selectedVersion", session.NegotiatedVersion) @@ -156,7 +156,7 @@ func majorMinor(v string) (string, string, bool) { func (s *sessionService) resumeCountersConsistent(ctx context.Context, bsEUI uint64, bsOpId, scOpId *int64, knownBsOpId, knownScOpId int64) bool { if bsOpId != nil && *bsOpId > knownBsOpId { // The BS requires a minimum operation state the SC does not know - s.logger.WarnContext(ctx, "Resume rejected: required BS operation ID beyond persisted state", + s.logger.WarnContext(ctx, bssci.LogBSSCIResumeRejectedBsOpIDBeyondPersisted, "bsEui", bsEUI, "requiredBsOpId", *bsOpId, "persistedBsOpId", knownBsOpId) @@ -164,14 +164,14 @@ func (s *sessionService) resumeCountersConsistent(ctx context.Context, bsEUI uin } if scOpId != nil && *scOpId < knownScOpId { // The BS claims a more negative SC operation ID than the SC issued - s.logger.WarnContext(ctx, "Resume rejected: claimed SC operation ID beyond issued state", + s.logger.WarnContext(ctx, bssci.LogBSSCIResumeRejectedScOpIDBeyondIssued, "bsEui", bsEUI, "claimedScOpId", *scOpId, "issuedScOpId", knownScOpId) return false } if scOpId != nil && *scOpId != knownScOpId { - s.logger.WarnContext(ctx, "Resume accepted with stale BS counter (SC is authoritative)", + s.logger.WarnContext(ctx, bssci.LogBSSCIResumeAcceptedStaleBsCounter, "bsEui", bsEUI, "bsReportedScOpId", *scOpId, "scAuthoritativeOpId", knownScOpId) diff --git a/KC-Core/internal/services/bssci/status_service.go b/KC-Core/internal/services/bssci/status_service.go index 6b82f7c..bf9ad3b 100644 --- a/KC-Core/internal/services/bssci/status_service.go +++ b/KC-Core/internal/services/bssci/status_service.go @@ -48,7 +48,7 @@ func (s *statusService) RecordPendingOperation(ctx context.Context, session *bss // operation whose recovery record does not exist. operationData, err := json.Marshal(op.Message) if err != nil { - s.logger.ErrorContext(ctx, "Failed to marshal pending operation", + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToMarshalPendingOperation, "error", err, "opId", opId) return err @@ -58,7 +58,7 @@ func (s *statusService) RecordPendingOperation(ctx context.Context, session *bss if op.Metadata != nil { metadataBytes, err := json.Marshal(op.Metadata) if err != nil { - s.logger.ErrorContext(ctx, "Failed to marshal pending operation metadata", + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToMarshalPendingOperationMetadata, "error", err, "opId", opId) return err @@ -92,7 +92,7 @@ func (s *statusService) RecordPendingOperations(ctx context.Context, session *bs for _, op := range ops { operationData, err := json.Marshal(op.Message) if err != nil { - s.logger.ErrorContext(ctx, "Failed to marshal pending operation", + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToMarshalPendingOperation, "error", err, "opId", op.OperationID) return err @@ -102,7 +102,7 @@ func (s *statusService) RecordPendingOperations(ctx context.Context, session *bs if op.Metadata != nil { metadataBytes, err := json.Marshal(op.Metadata) if err != nil { - s.logger.ErrorContext(ctx, "Failed to marshal pending operation metadata", + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToMarshalPendingOperationMetadata, "error", err, "opId", op.OperationID) return err diff --git a/KC-Core/internal/services/bssci/status_service_integration_test.go b/KC-Core/internal/services/bssci/status_service_integration_test.go index 8c96170..8e7f750 100644 --- a/KC-Core/internal/services/bssci/status_service_integration_test.go +++ b/KC-Core/internal/services/bssci/status_service_integration_test.go @@ -11,6 +11,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockPendingOperationRepositoryWithCallTracking extends mockPendingOperationRepository with call count tracking @@ -89,7 +91,7 @@ func TestStatusServiceMultiSessionIsolation(t *testing.T) { }, } - ctx := context.Background() + ctx := testutil.TestContext() // Same opId used across all sessions (simulates concurrent downlink operations) opId := int64(-100) @@ -218,7 +220,7 @@ func TestStatusServiceCachePopulationForNewOperations(t *testing.T) { }, } - ctx := context.Background() + ctx := testutil.TestContext() // Record a NEW operation (simulates initDLDataQue calling persistPendingOperation) opId := int64(-200) @@ -280,7 +282,7 @@ func TestStatusServiceSingleWriterPattern(t *testing.T) { }, } - ctx := context.Background() + ctx := testutil.TestContext() opId := int64(-300) op := &bssci.PendingOperation{ diff --git a/KC-Core/internal/services/bssci/test_helpers.go b/KC-Core/internal/services/bssci/test_helpers.go index 1a59d1a..ddd1d6e 100644 --- a/KC-Core/internal/services/bssci/test_helpers.go +++ b/KC-Core/internal/services/bssci/test_helpers.go @@ -870,7 +870,7 @@ func CreateTestServices(log logger.Logger, eventStore interfaces.SystemEventStor bssci.SessionService, bssci.DownlinkService, bssci.StatusService, - bssci.ConnectionService, + bssci.BaseStationConnectionRegistry, bssci.SCACIBroadcaster, bssci.QueueSerializer, bssci.AuditLogger, @@ -895,8 +895,9 @@ func CreateTestServices(log logger.Logger, eventStore interfaces.SystemEventStor var testMu sync.RWMutex statusSvc := NewStatusService(&testPendingOps, &testMu, &mockPendingOperationRepository{}, log) - // ConnectionService - stateless, only needs logger - connectionSvc := NewConnectionService(log) + // Connection registry over a nil manager mock is unusable; tests that + // exercise registration provide their own fake + var connectionSvc bssci.BaseStationConnectionRegistry // SCACIForwarder - initially unwired, safe to call (returns nil if no SCACI) broadcaster := NewSCACIForwarder(log) diff --git a/KC-Core/internal/services/bssci/version_negotiator_test.go b/KC-Core/internal/services/bssci/version_negotiator_test.go index 02876da..972b44f 100644 --- a/KC-Core/internal/services/bssci/version_negotiator_test.go +++ b/KC-Core/internal/services/bssci/version_negotiator_test.go @@ -1,7 +1,6 @@ package bssciservices import ( - "context" "fmt" "testing" @@ -9,6 +8,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestNewVersionNegotiatorSetValidation verifies the constructor rejects @@ -77,7 +78,7 @@ func TestNegotiateSelection(t *testing.T) { neg, err := NewVersionNegotiator(tt.supported, &mockLogger{}) require.NoError(t, err) - selected, negErr := neg.Negotiate(context.Background(), tt.requested) + selected, negErr := neg.Negotiate(testutil.TestContext(), tt.requested) if tt.errToken != "" { require.Error(t, negErr) catErr, ok := negErr.(*bssci.CatalogError) @@ -106,7 +107,7 @@ func TestNegotiatePatchIgnored(t *testing.T) { for _, patch := range []int{0, 1, 5, 99} { requested := fmt.Sprintf("%d.%d.%d", scMajor, scMinor, patch) t.Run(fmt.Sprintf("patch_%d", patch), func(t *testing.T) { - selected, negErr := neg.Negotiate(context.Background(), requested) + selected, negErr := neg.Negotiate(testutil.TestContext(), requested) require.NoError(t, negErr, "Patch diff should not cause incompatibility (BSSCI-2.3-01)") assert.Equal(t, mioty.MIOTYProtocolVersion, selected) }) diff --git a/KC-Core/internal/services/certificates/service.go b/KC-Core/internal/services/certificates/service.go index df97072..2c3f96a 100644 --- a/KC-Core/internal/services/certificates/service.go +++ b/KC-Core/internal/services/certificates/service.go @@ -29,6 +29,16 @@ import ( "github.com/google/uuid" ) +// Certificate generator flags and canonical certificate file names. +const ( + certGenFlagDir = "-dir" + certGenFlagDays = "-days" + + caCertFileName = "ca.crt" + caKeyFileName = "ca.key" + serverCertFileName = "server.crt" +) + // KeyEncryptor is the narrow key-encryption contract the certificate service // consumes (implemented by crypto.KeyEncryptor). type KeyEncryptor interface { @@ -110,7 +120,7 @@ func New(cfg *config.Config, log logger.Logger, bsRepo interfaces.BaseStationRep } if _, err := os.Stat(certsDir); err != nil { - log.WarnContext(ctx, "Certificate directory not found", + log.WarnContext(ctx, pkggrpc.LogCertDirectoryNotFound, "path", certsDir, "hint", "Run certgen to generate certificates before starting") } @@ -119,7 +129,7 @@ func New(cfg *config.Config, log logger.Logger, bsRepo interfaces.BaseStationRep serverValidityDays := cfg.Certificates.ServerValidityDays if serverValidityDays <= 0 { serverValidityDays = config.DefaultCertificatesServerValidityDays - log.WarnContext(ctx, "Invalid server_validity_days, using default", "default", serverValidityDays) + log.WarnContext(ctx, pkggrpc.LogCertInvalidServerValidityDays, "default", serverValidityDays) } // Ensure temp directory exists @@ -197,8 +207,8 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer // Copy the existing CA certificate from KC-Core instead of generating a new one kcCoreCertsPath := s.certsDir - caCertSrc := filepath.Clean(filepath.Join(kcCoreCertsPath, "ca.crt")) - caCertDst := filepath.Join(certDir, "ca.crt") + caCertSrc := filepath.Clean(filepath.Join(kcCoreCertsPath, caCertFileName)) + caCertDst := filepath.Join(certDir, caCertFileName) // Copy CA certificate caCertData, err := os.ReadFile(caCertSrc) // #nosec G304 - path validated via filepath.Clean @@ -221,8 +231,8 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer s.logger.InfoContext(ctx, pkggrpc.LogCertCACertCopied, "from", caCertSrc, "to", caCertDst) // Also copy the CA key so we can sign client certificates - caKeySrc := filepath.Clean(filepath.Join(kcCoreCertsPath, "ca.key")) - caKeyDst := filepath.Join(certDir, "ca.key") + caKeySrc := filepath.Clean(filepath.Join(kcCoreCertsPath, caKeyFileName)) + caKeyDst := filepath.Join(certDir, caKeyFileName) caKeyData, err := os.ReadFile(caKeySrc) // #nosec G304 - path validated via filepath.Clean if err != nil { @@ -243,8 +253,8 @@ func (s *Service) GenerateCertificate(ctx context.Context, req *grpcservices.Cer // Execute certificate generation with -client-only flag genArgs := []string{ - "-dir", certDir, - "-days", fmt.Sprintf("%d", req.ValidityDays), + certGenFlagDir, certDir, + certGenFlagDays, fmt.Sprintf("%d", req.ValidityDays), "-client", bsEUIDashed, "-client-only", } @@ -354,10 +364,10 @@ func (s *Service) DownloadCertificateByID(ctx context.Context, certType, certID var filename, downloadName string switch certType { case pkggrpc.CertTypeCA: - filename = "ca.crt" + filename = caCertFileName downloadName = "kilocenter-ca-certificate.crt" case "server": - filename = "server.crt" + filename = serverCertFileName downloadName = fmt.Sprintf("basestation-%s-server-certificate.crt", euiPart) case pkggrpc.CertTypeClient: filename = "client.crt" @@ -437,8 +447,8 @@ func (s *Service) GenerateServerCertificates(ctx context.Context) error { // Execute certificate generation for server certificates serverHostname := s.deriveServerHostname() genArgs := []string{ - "-dir", kcCorePath, - "-days", fmt.Sprintf("%d", s.serverValidityDays), + certGenFlagDir, kcCorePath, + certGenFlagDays, fmt.Sprintf("%d", s.serverValidityDays), "-server", serverHostname, } s.logger.InfoContext(ctx, pkggrpc.LogServerCertGenExecuting, "command", s.certGenPath, "args", genArgs, "hostname", serverHostname) @@ -465,23 +475,23 @@ func (s *Service) RenewServerCertificates(ctx context.Context) error { kcCorePath := s.certsDir // Require existing server certificate (nothing to renew otherwise) - if _, err := os.Stat(filepath.Join(kcCorePath, "server.crt")); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(kcCorePath, serverCertFileName)); os.IsNotExist(err) { return pkggrpc.NewTokenError(pkggrpc.ErrTokenNoCertsToRenew, nil) } // Require CA files (-server-only needs them to sign) - if _, err := os.Stat(filepath.Join(kcCorePath, "ca.crt")); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(kcCorePath, caCertFileName)); os.IsNotExist(err) { return pkggrpc.NewTokenError(pkggrpc.ErrTokenCACertReadFailed, nil) } - if _, err := os.Stat(filepath.Join(kcCorePath, "ca.key")); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(kcCorePath, caKeyFileName)); os.IsNotExist(err) { return pkggrpc.NewTokenError(pkggrpc.ErrTokenCAKeyReadFailed, nil) } // Execute certgen with -server-only to regenerate server cert without touching the CA serverHostname := s.deriveServerHostname() genArgs := []string{ - "-dir", kcCorePath, - "-days", fmt.Sprintf("%d", s.serverValidityDays), + certGenFlagDir, kcCorePath, + certGenFlagDays, fmt.Sprintf("%d", s.serverValidityDays), "-server", serverHostname, "-server-only", } @@ -510,7 +520,7 @@ func (s *Service) GetServerCertificateStatus(_ context.Context) (*grpcservices.C kcCorePath := s.certsDir // Check server certificate - serverCertPath := filepath.Join(kcCorePath, "server.crt") + serverCertPath := filepath.Join(kcCorePath, serverCertFileName) if serverInfo := s.getCertificateInfo(serverCertPath); serverInfo != nil { status.HasServerCert = true status.ServerCertExpiry = &serverInfo.ExpiryDate @@ -520,7 +530,7 @@ func (s *Service) GetServerCertificateStatus(_ context.Context) (*grpcservices.C } // Check CA certificate - caCertPath := filepath.Join(kcCorePath, "ca.crt") + caCertPath := filepath.Join(kcCorePath, caCertFileName) if caInfo := s.getCertificateInfo(caCertPath); caInfo != nil { status.HasCACert = true status.CACertExpiry = &caInfo.ExpiryDate @@ -672,9 +682,9 @@ func (s *Service) persistCertsToBaseStation(ctx context.Context, certDir string, } // Read certificate files with proper error handling - caCertData, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) // #nosec G304 - path from UUID-based certDir + caCertData, err := os.ReadFile(filepath.Join(certDir, caCertFileName)) // #nosec G304 - path from UUID-based certDir if err != nil { - s.logger.ErrorContext(ctx, pkggrpc.LogCertFileReadFailed, "file", "ca.crt", "error", err) + s.logger.ErrorContext(ctx, pkggrpc.LogCertFileReadFailed, "file", caCertFileName, "error", err) return pkggrpc.NewTokenError(pkggrpc.ErrTokenCACertReadFailed, nil) } clientCertData, err := os.ReadFile(filepath.Join(certDir, "client.crt")) // #nosec G304 - path from UUID-based certDir diff --git a/KC-Core/internal/services/federation/relay_client.go b/KC-Core/internal/services/federation/relay_client.go index 997b8bd..8f4167a 100644 --- a/KC-Core/internal/services/federation/relay_client.go +++ b/KC-Core/internal/services/federation/relay_client.go @@ -97,7 +97,11 @@ func (c *RelayClient) Start(ctx context.Context) error { c.ceID = inst.CEID c.company = inst.CompanyName - relayCtx, cancel := context.WithCancel(ctx) + // The relay loop must outlive the caller's context: Start is reached from + // request-scoped paths (CompleteCEOnboarding), and inheriting their + // cancellation would kill the loop as soon as the RPC returns. Stop() + // remains the only shutdown path. + relayCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) c.cancelRelay = cancel go c.loop(relayCtx, inst) return nil diff --git a/KC-Core/internal/services/scaci/dl_service.go b/KC-Core/internal/services/scaci/dl_service.go index 55d3871..b54217e 100644 --- a/KC-Core/internal/services/scaci/dl_service.go +++ b/KC-Core/internal/services/scaci/dl_service.go @@ -152,7 +152,7 @@ func (dls *dlService) QueueDownlink( } // Success - use queuedQueId from scheduler (may differ from request) - dls.logger.Debug(scaci.LogSCACIDLDataQueueProcessed, + dls.logger.DebugContext(ctx, scaci.LogSCACIDLDataQueueProcessed, "queId", queuedQueId, "bsEui", bsEui, "epEui", req.EpEui) @@ -221,7 +221,7 @@ func (dls *dlService) RevokeDownlink( } // Success - dls.logger.Debug(scaci.LogSCACIDLRevokeSuccessful, + dls.logger.DebugContext(ctx, scaci.LogSCACIDLRevokeSuccessful, "queId", queId, "bsEui", bsEui, "tenantId", tenantID) diff --git a/KC-Core/internal/services/scaci/dl_service_test.go b/KC-Core/internal/services/scaci/dl_service_test.go index cf5d220..59ce4e2 100644 --- a/KC-Core/internal/services/scaci/dl_service_test.go +++ b/KC-Core/internal/services/scaci/dl_service_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -71,7 +73,7 @@ func TestDLService_RevokeDownlink_Success(t *testing.T) { // Create service and call svc := NewDLService(mockScheduler, nil, log) - bsEui, errToken := svc.RevokeDownlink(context.Background(), queId, tenantID) + bsEui, errToken := svc.RevokeDownlink(testutil.TestContext(), queId, tenantID) // Assert results assert.Equal(t, expectedBsEui, bsEui) @@ -99,7 +101,7 @@ func TestDLService_RevokeDownlink_QueueNotFound_MapsToDownlinkNotFound(t *testin // Create service and call svc := NewDLService(mockScheduler, nil, log) - bsEui, errToken := svc.RevokeDownlink(context.Background(), queId, tenantID) + bsEui, errToken := svc.RevokeDownlink(testutil.TestContext(), queId, tenantID) // Assert results assert.Equal(t, uint64(0), bsEui) @@ -127,7 +129,7 @@ func TestDLService_RevokeDownlink_NoResources_MapsToSchedulerUnavailable(t *test // Create service and call svc := NewDLService(mockScheduler, nil, log) - bsEui, errToken := svc.RevokeDownlink(context.Background(), queId, tenantID) + bsEui, errToken := svc.RevokeDownlink(testutil.TestContext(), queId, tenantID) // Assert results assert.Equal(t, uint64(0), bsEui) diff --git a/KC-Core/internal/services/scaci/endpoint_service.go b/KC-Core/internal/services/scaci/endpoint_service.go index de96878..7d5ce5c 100644 --- a/KC-Core/internal/services/scaci/endpoint_service.go +++ b/KC-Core/internal/services/scaci/endpoint_service.go @@ -133,7 +133,7 @@ func (es *endpointService) Register( endpoint, err := es.endpointRepo.GetByEUI(dbCtx, tenantID, eui[:]) if err != nil && !errors.Is(err, storage.ErrNotFound) { - es.logger.Error(scaci.LogSCACIDatabaseErrorRegister, + es.logger.ErrorContext(ctx, scaci.LogSCACIDatabaseErrorRegister, "epEui", pkgmioty.FormatEUI64(req.EpEui), "tenantId", tenantID, "error", err) @@ -170,21 +170,21 @@ func (es *endpointService) Register( Tags: make(map[string]string), } if err := es.endpointRepo.Create(dbCtx, newEndpoint); err != nil { - es.logger.Error(scaci.LogSCACICreateEndpointFailed, + es.logger.ErrorContext(ctx, scaci.LogSCACICreateEndpointFailed, "epEui", pkgmioty.FormatEUI64(req.EpEui), "tenantId", tenantID, "error", err) return scaci.ErrFailedCreateEndpoint } endpoint = newEndpoint - es.logger.Info(scaci.LogSCACIEndpointCreated, + es.logger.InfoContext(ctx, scaci.LogSCACIEndpointCreated, "epEui", pkgmioty.FormatEUI64(req.EpEui), "tenantId", tenantID) } // Step 7: Apply MIOTY field updates (both create and update paths) if err := es.endpointRepo.UpdateFields(dbCtx, tenantID, endpoint.ID, updates); err != nil { - es.logger.Error(scaci.LogSCACIUpdateEndpointFailed, + es.logger.ErrorContext(ctx, scaci.LogSCACIUpdateEndpointFailed, "epEui", pkgmioty.FormatEUI64(req.EpEui), "tenantId", tenantID, "endpointId", endpoint.ID, @@ -192,7 +192,7 @@ func (es *endpointService) Register( return scaci.ErrFailedUpdateEndpoint } - es.logger.Info(scaci.LogSCACIEndpointRegistered, + es.logger.InfoContext(ctx, scaci.LogSCACIEndpointRegistered, "epEui", pkgmioty.FormatEUI64(req.EpEui), "tenantId", tenantID, "bidi", req.Bidi, @@ -244,12 +244,12 @@ func (es *endpointService) Deregister( endpointRecord, err := es.endpointRepo.GetByEUI(dbCtx, tenantID, eui[:]) if err != nil { if errors.Is(err, storage.ErrNotFound) { - es.logger.Warn(scaci.LogSCACIEndpointNotFoundDeregister, + es.logger.WarnContext(ctx, scaci.LogSCACIEndpointNotFoundDeregister, "epEui", pkgmioty.FormatEUI64(epEui), "tenantId", tenantID) return scaci.ErrEndpointNotFound } - es.logger.Error(scaci.LogSCACIDatabaseErrorDeregister, + es.logger.ErrorContext(ctx, scaci.LogSCACIDatabaseErrorDeregister, "epEui", pkgmioty.FormatEUI64(epEui), "tenantId", tenantID, "error", err) @@ -258,14 +258,14 @@ func (es *endpointService) Deregister( // Step 5: Call shared detach helper (marks endpoint inactive, no telemetry) if err := endpoint.DetachEndpoint(dbCtx, es.endpointRepo, tenantID, endpointRecord.ID, nil); err != nil { - es.logger.Error(scaci.LogSCACIDetachEndpointFailed, + es.logger.ErrorContext(ctx, scaci.LogSCACIDetachEndpointFailed, "epEui", pkgmioty.FormatEUI64(epEui), "tenantId", tenantID, "error", err) return scaci.ErrFailedUpdateEndpoint } - es.logger.Info(scaci.LogSCACIEndpointDeregistered, + es.logger.InfoContext(ctx, scaci.LogSCACIEndpointDeregistered, "epEui", pkgmioty.FormatEUI64(epEui), "tenantId", tenantID) @@ -298,7 +298,7 @@ func (es *endpointService) GetByEUI( ) (*models.EndPoint, string) { // Step 1: Validate EUI length if len(eui) != 8 { - es.logger.Error("Invalid EUI length for GetByEUI", + es.logger.ErrorContext(ctx, "Invalid EUI length for GetByEUI", "length", len(eui), "tenantId", tenantID) return nil, scaci.ErrMissingEpEui @@ -312,11 +312,11 @@ func (es *endpointService) GetByEUI( endpoint, err := es.endpointRepo.GetByEUI(dbCtx, tenantID, eui) if err != nil { if errors.Is(err, storage.ErrNotFound) { - es.logger.Debug("Endpoint not found in GetByEUI", + es.logger.DebugContext(ctx, "Endpoint not found in GetByEUI", "tenantId", tenantID) return nil, scaci.ErrEndpointNotFound } - es.logger.Error(scaci.LogSCACIDatabaseErrorRegister, + es.logger.ErrorContext(ctx, scaci.LogSCACIDatabaseErrorRegister, "tenantId", tenantID, "error", err) return nil, scaci.ErrDatabaseError @@ -343,7 +343,7 @@ func (es *endpointService) GetGlobal( ) (*models.EndPoint, string) { // Step 1: Validate EUI length (matching GetByEUI pattern) if len(eui) != 8 { - es.logger.Error("Invalid EUI length for GetGlobal", + es.logger.ErrorContext(ctx, "Invalid EUI length for GetGlobal", "length", len(eui)) return nil, scaci.ErrMissingEpEui } @@ -360,10 +360,10 @@ func (es *endpointService) GetGlobal( endpoint, err := es.endpointRepo.Get(dbCtx, euiModel) if err != nil { if errors.Is(err, storage.ErrNotFound) { - es.logger.Debug("Endpoint not found in GetGlobal (cross-tenant)") + es.logger.DebugContext(ctx, "Endpoint not found in GetGlobal (cross-tenant)") return nil, scaci.ErrEndpointNotFound } - es.logger.Error(scaci.LogSCACIDatabaseErrorRegister, + es.logger.ErrorContext(ctx, scaci.LogSCACIDatabaseErrorRegister, "error", err) return nil, scaci.ErrDatabaseError } diff --git a/KC-Core/internal/services/scaci/endpoint_service_test.go b/KC-Core/internal/services/scaci/endpoint_service_test.go index 1fb7963..3e4c95e 100644 --- a/KC-Core/internal/services/scaci/endpoint_service_test.go +++ b/KC-Core/internal/services/scaci/endpoint_service_test.go @@ -18,6 +18,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockEndpointRepo implements interfaces.EndpointRepository for testing @@ -220,7 +222,7 @@ func TestEndpointService_Register_ValidationGuards(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - errToken := svc.Register(context.Background(), tt.req, 1) + errToken := svc.Register(testutil.TestContext(), tt.req, 1) if tt.wantNoErr { assert.Empty(t, errToken, "expected no error token") @@ -250,7 +252,7 @@ func TestEndpointService_Register_StoresUnsignedValues(t *testing.T) { PacketCnt: 4294967295, // Max uint32 } - errToken := svc.Register(context.Background(), req, 1) + errToken := svc.Register(testutil.TestContext(), req, 1) assert.Empty(t, errToken, "Register should succeed with max values") // Verify the endpoint was created with correct values @@ -286,7 +288,7 @@ func TestEndpointService_Deregister_ValidationGuards(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - errToken := svc.Deregister(context.Background(), tt.epEui, 1) + errToken := svc.Deregister(testutil.TestContext(), tt.epEui, 1) assert.Equal(t, tt.wantErr, errToken) }) } @@ -320,7 +322,7 @@ func TestEndpointService_GetByEUI_ValidationGuards(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ep, errToken := svc.GetByEUI(context.Background(), 1, tt.eui) + ep, errToken := svc.GetByEUI(testutil.TestContext(), 1, tt.eui) assert.Equal(t, tt.wantErr, errToken) if tt.wantEpNil { @@ -356,7 +358,7 @@ func TestRegister_AllFields_PersistsCorrectly(t *testing.T) { LongBlkDist: true, } - errToken := svc.Register(context.Background(), req, 1) + errToken := svc.Register(testutil.TestContext(), req, 1) assert.Empty(t, errToken, "Register should succeed with all fields") // Verify UpdateFields was called with exact DB column names @@ -411,7 +413,7 @@ func TestRegister_ZeroEpEui_ReturnsError(t *testing.T) { NwkKey: validNwkKey, } - errToken := svc.Register(context.Background(), req, 1) + errToken := svc.Register(testutil.TestContext(), req, 1) assert.Equal(t, scaci.ErrMissingEpEui, errToken, "Zero EpEui must return ErrMissingEpEui") } @@ -432,7 +434,7 @@ func TestRegister_MaxShAddr_65535(t *testing.T) { ShAddr: 65535, // Max uint16 } - errToken := svc.Register(context.Background(), req, 1) + errToken := svc.Register(testutil.TestContext(), req, 1) assert.Empty(t, errToken, "Max uint16 ShAddr should succeed") // Verify stored as int32(65535) without truncation @@ -457,7 +459,7 @@ func TestRegister_MaxAttachCnt_4294967295(t *testing.T) { AttachCnt: 4294967295, // Max uint32 } - errToken := svc.Register(context.Background(), req, 1) + errToken := svc.Register(testutil.TestContext(), req, 1) assert.Empty(t, errToken, "Max uint32 AttachCnt should succeed") // Verify stored as int64(4294967295) without truncation @@ -482,7 +484,7 @@ func TestRegister_MaxPacketCnt_4294967295(t *testing.T) { PacketCnt: 4294967295, // Max uint32 } - errToken := svc.Register(context.Background(), req, 1) + errToken := svc.Register(testutil.TestContext(), req, 1) assert.Empty(t, errToken, "Max uint32 PacketCnt should succeed") // Verify stored as int64(4294967295) without truncation diff --git a/KC-Core/internal/services/scaci/handshake_service.go b/KC-Core/internal/services/scaci/handshake_service.go index 8439a74..3e942fb 100644 --- a/KC-Core/internal/services/scaci/handshake_service.go +++ b/KC-Core/internal/services/scaci/handshake_service.go @@ -144,13 +144,13 @@ func (hs *handshakeService) ValidateConnect( ) (*scaci.Session, *scaci.ConnectResponse, string) { // Guard 1: Nil certificate check (prevents panic on cert.Subject) if cert == nil { - hs.logger.Error(scaci.LogSCACINoClientCertificate) + hs.logger.ErrorContext(ctx, scaci.LogSCACINoClientCertificate) return nil, nil, scaci.ErrNilCertificate } // Guard 2: Nil resolver check (defensive - shouldn't happen if wired correctly) if hs.orgResolver == nil { - hs.logger.Error(scaci.LogSCACIOrgResolverNotInjected) + hs.logger.ErrorContext(ctx, scaci.LogSCACIOrgResolverNotInjected) return nil, nil, scaci.ErrNilCertificate } @@ -167,7 +167,7 @@ func (hs *handshakeService) ValidateConnect( if err != nil { // Strict mode: fail-closed on org resolution failure (production/managed cloud) if hs.strictOrgResolution { - hs.logger.Error(scaci.LogSCACICertificateMappingFailed, + hs.logger.ErrorContext(ctx, scaci.LogSCACICertificateMappingFailed, "error", err, "certCN", cert.Subject.CommonName, "strictMode", true) @@ -175,7 +175,7 @@ func (hs *handshakeService) ValidateConnect( } // Community mode: fall back to default tenant ID - hs.logger.Warn("Certificate org resolution failed, using community fallback", + hs.logger.WarnContext(ctx, "Certificate org resolution failed, using community fallback", "error", err, "certCN", cert.Subject.CommonName, "defaultTenantID", hs.defaultTenantID) @@ -183,14 +183,14 @@ func (hs *handshakeService) ValidateConnect( tenantID = hs.defaultTenantID orgID, err = hs.orgResolver.GetDefaultOrgForTenant(ctx, tenantID) if err != nil { - hs.logger.Error(scaci.LogSCACICertificateMappingFailed, + hs.logger.ErrorContext(ctx, scaci.LogSCACICertificateMappingFailed, "error", err, "tenantID", tenantID) return nil, nil, scaci.ErrCertificateTenantResolutionFailed } } - hs.logger.Debug(scaci.LogSCACIProcessingConnect, + hs.logger.DebugContext(ctx, scaci.LogSCACIProcessingConnect, "tenantID", tenantID, "orgID", orgID.String(), "certCN", cert.Subject.CommonName) @@ -220,7 +220,7 @@ func (hs *handshakeService) ValidateConnect( req.Version, // SCACI §§2.1-2.3: validate version matches stored negotiated_version ) if errToken != "" { - hs.logger.Warn(scaci.LogSCACIResumeFailed, + hs.logger.WarnContext(ctx, scaci.LogSCACIResumeFailed, "acEui", pkgmioty.FormatEUI64(req.AcEui), "reason", errToken) // Resume failure is not fatal - create new session instead @@ -233,7 +233,7 @@ func (hs *handshakeService) ValidateConnect( if err == nil && dbSession != nil { // Validate certificate tenant matches stored session tenant if dbSession.TenantID != tenantID { - hs.logger.Error(scaci.LogSCACICrossTenantResumeRejected, + hs.logger.ErrorContext(ctx, scaci.LogSCACICrossTenantResumeRejected, "certTenantID", tenantID, "sessionTenantID", dbSession.TenantID) // Fail-closed: reject cross-tenant resume attempts @@ -245,7 +245,7 @@ func (hs *handshakeService) ValidateConnect( session.Resumed = true canResume = true - hs.logger.Info(scaci.LogSCACISessionResumed, + hs.logger.InfoContext(ctx, scaci.LogSCACISessionResumed, "acEui", pkgmioty.FormatEUI64(req.AcEui), "snAcUuid", scaci.FormatUUID(req.SnAcUUID)) } @@ -258,7 +258,7 @@ func (hs *handshakeService) ValidateConnect( session.OrganizationID = orgID canResume = false - hs.logger.Info(scaci.LogSCACINewSessionCreated, + hs.logger.InfoContext(ctx, scaci.LogSCACINewSessionCreated, "acEui", pkgmioty.FormatEUI64(req.AcEui), "snScUuid", scaci.FormatUUID(session.SnScUUID), "orgID", orgID.String()) @@ -287,11 +287,11 @@ func (hs *handshakeService) ValidateConnect( // TODO: Validate software version (non-fatal per SCACI §3.3.2 - swVersion is optional) switch hs.scSwVersion { case "": - hs.logger.Warn("Software version not configured - ConnectResponse will omit swVersion field", + hs.logger.WarnContext(ctx, "Software version not configured - ConnectResponse will omit swVersion field", "tenantID", tenantID, "acEui", pkgmioty.FormatEUI64(req.AcEui)) case "dev", "dev-local": - hs.logger.Warn("Using development software version in ConnectResponse", + hs.logger.WarnContext(ctx, "Using development software version in ConnectResponse", "swVersion", hs.scSwVersion, "tenantID", tenantID, "acEui", pkgmioty.FormatEUI64(req.AcEui)) @@ -404,7 +404,7 @@ func (hs *handshakeService) ResolveResume( // Convert acUUID to [16]byte for repository call var acUUIDBytes [16]byte if len(acUUID) != 16 { - hs.logger.Error(scaci.LogSCACIInvalidAcUUIDLength, + hs.logger.ErrorContext(ctx, scaci.LogSCACIInvalidAcUUIDLength, "length", len(acUUID)) return false, scaci.ErrSnAcUUIDZero } @@ -414,14 +414,14 @@ func (hs *handshakeService) ResolveResume( // Pass both acOpId and scOpId for full opId parity validation per SCACI-S.1-05/3.2-03 resumptionInfo, err := hs.sessionRepo.CheckSessionResumable(ctx, tenantID, acUUIDBytes, acOpId, scOpId) if err != nil { - hs.logger.Debug(scaci.LogSCACIResumeFailed, + hs.logger.DebugContext(ctx, scaci.LogSCACIResumeFailed, "error", err, "tenantID", tenantID) return false, scaci.ErrNoActiveSession } if resumptionInfo == nil { - hs.logger.Debug(scaci.LogSCACINoResumableSession, + hs.logger.DebugContext(ctx, scaci.LogSCACINoResumableSession, "tenantID", tenantID) return false, scaci.ErrNoActiveSession } @@ -429,7 +429,7 @@ func (hs *handshakeService) ResolveResume( // Validate operation ID consistency // CheckSessionResumable already validated this if !resumptionInfo.CanResume { - hs.logger.Warn(scaci.LogSCACISessionCannotResume, + hs.logger.WarnContext(ctx, scaci.LogSCACISessionCannotResume, "acOpId", acOpId, "scOpId", scOpId, "reason", resumptionInfo.ReasonIfNotResumable) @@ -444,7 +444,7 @@ func (hs *handshakeService) ResolveResume( // Fail-closed on parse errors (should not happen if versions were validated at connect) if storedErr != nil || reqErr != nil { - hs.logger.Warn(scaci.LogSCACIVersionMismatchOnResume, + hs.logger.WarnContext(ctx, scaci.LogSCACIVersionMismatchOnResume, "storedVersion", resumptionInfo.NegotiatedVersion, "requestVersion", requestVersion, "storedParseErr", storedErr, @@ -455,7 +455,7 @@ func (hs *handshakeService) ResolveResume( // §2.3: Compare major.minor only, ignore patch if storedMajor != reqMajor || storedMinor != reqMinor { - hs.logger.Warn(scaci.LogSCACIVersionMismatchOnResume, + hs.logger.WarnContext(ctx, scaci.LogSCACIVersionMismatchOnResume, "storedMajor", storedMajor, "storedMinor", storedMinor, "reqMajor", reqMajor, "reqMinor", reqMinor, "tenantID", tenantID) diff --git a/KC-Core/internal/services/scaci/session_persistence.go b/KC-Core/internal/services/scaci/session_persistence.go index 209b092..c46cffe 100644 --- a/KC-Core/internal/services/scaci/session_persistence.go +++ b/KC-Core/internal/services/scaci/session_persistence.go @@ -74,7 +74,7 @@ func (p *sessionPersistence) PersistConnectAsync(session *scaci.Session, certFin } if err := p.sessionRepo.UpdateSession(ctx, session.TenantID, session.ID, updateReq); err != nil { - p.logger.Error(scaci.LogSCACIUpdateSessionFailed, "error", err) + p.logger.ErrorContext(ctx, scaci.LogSCACIUpdateSessionFailed, "error", err) } } else { // Create new session (fresh connection) diff --git a/KC-Core/internal/services/scaci/session_persistence_test.go b/KC-Core/internal/services/scaci/session_persistence_test.go index d1a9bc5..3b1c80c 100644 --- a/KC-Core/internal/services/scaci/session_persistence_test.go +++ b/KC-Core/internal/services/scaci/session_persistence_test.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -706,7 +708,7 @@ func TestPersistHeartbeatAsync_CallsUpdateHeartbeat(t *testing.T) { TenantID: 42, } - svc.PersistHeartbeatAsync(context.Background(), session) + svc.PersistHeartbeatAsync(testutil.TestContext(), session) mockRepo.waitForCompletion() assert.True(t, mockRepo.wasHeartbeatCalled(), "UpdateHeartbeat should be called") @@ -728,7 +730,7 @@ func TestPersistHeartbeatAsync_CorrectIDs(t *testing.T) { TenantID: expectedTenantID, } - svc.PersistHeartbeatAsync(context.Background(), session) + svc.PersistHeartbeatAsync(testutil.TestContext(), session) mockRepo.waitForCompletion() tenantID, sessionID := mockRepo.getLastHeartbeatIDs() @@ -745,7 +747,7 @@ func TestPersistHeartbeatAsync_SkipsNilSession(t *testing.T) { svc := NewSessionPersistence(mockRepo, log) // Should not panic - svc.PersistHeartbeatAsync(context.Background(), nil) + svc.PersistHeartbeatAsync(testutil.TestContext(), nil) // Give goroutine time to execute if it were to (it shouldn't) time.Sleep(50 * time.Millisecond) @@ -766,7 +768,7 @@ func TestPersistHeartbeatAsync_SkipsZeroID(t *testing.T) { TenantID: 42, } - svc.PersistHeartbeatAsync(context.Background(), session) + svc.PersistHeartbeatAsync(testutil.TestContext(), session) // Give goroutine time to execute if it were to (it shouldn't) time.Sleep(50 * time.Millisecond) @@ -790,7 +792,7 @@ func TestPersistHeartbeatAsync_NonBlocking(t *testing.T) { } start := time.Now() - svc.PersistHeartbeatAsync(context.Background(), session) + svc.PersistHeartbeatAsync(testutil.TestContext(), session) elapsed := time.Since(start) // Should return almost immediately (not wait for 500ms mock delay) @@ -826,7 +828,7 @@ func TestPersistHeartbeatAsync_LogsErrorOnFailure(t *testing.T) { } // Should not panic even with error - svc.PersistHeartbeatAsync(context.Background(), session) + svc.PersistHeartbeatAsync(testutil.TestContext(), session) mockRepo.waitForCompletion() // Error is logged but method completes normally @@ -851,7 +853,7 @@ func TestPersistHeartbeatAsync_WarnContextFields(t *testing.T) { TenantID: expectedTenantID, } - svc.PersistHeartbeatAsync(context.Background(), session) + svc.PersistHeartbeatAsync(testutil.TestContext(), session) mockRepo.waitForCompletion() // Wait for WarnContext call (deterministic) diff --git a/KC-Core/internal/services/scaci/ul_service.go b/KC-Core/internal/services/scaci/ul_service.go index 0f5adc4..d37b375 100644 --- a/KC-Core/internal/services/scaci/ul_service.go +++ b/KC-Core/internal/services/scaci/ul_service.go @@ -177,7 +177,7 @@ func (uls *ulService) ScheduleULTransmit( } // Success - uls.logger.Debug(scaci.LogSCACIULDataTxScheduled, + uls.logger.DebugContext(ctx, scaci.LogSCACIULDataTxScheduled, "bssciOpID", bssciOpID, "bsEui", actualBsEui, "epEui", req.EpEui) diff --git a/KC-Core/internal/services/scaci/ul_service_test.go b/KC-Core/internal/services/scaci/ul_service_test.go index b8a9a74..539015a 100644 --- a/KC-Core/internal/services/scaci/ul_service_test.go +++ b/KC-Core/internal/services/scaci/ul_service_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -126,7 +128,7 @@ func TestULService_PreferenceFound_UsesPreferedBS(t *testing.T) { // Create service and call svc := NewULService(mockScheduler, mockStatus, log) - opID, actualBsEui, errToken := svc.ScheduleULTransmit(context.Background(), req, tenantID) + opID, actualBsEui, errToken := svc.ScheduleULTransmit(testutil.TestContext(), req, tenantID) // Assert results assert.Equal(t, expectedOpID, opID) @@ -176,7 +178,7 @@ func TestULService_NoPreference_FallsBackToScheduler(t *testing.T) { // Create service and call svc := NewULService(mockScheduler, mockStatus, log) - opID, actualBsEui, errToken := svc.ScheduleULTransmit(context.Background(), req, tenantID) + opID, actualBsEui, errToken := svc.ScheduleULTransmit(testutil.TestContext(), req, tenantID) // Assert results assert.Equal(t, expectedOpID, opID) @@ -226,7 +228,7 @@ func TestULService_PreferenceLookupError_FallsBack(t *testing.T) { // Create service and call svc := NewULService(mockScheduler, mockStatus, log) - opID, actualBsEui, errToken := svc.ScheduleULTransmit(context.Background(), req, tenantID) + opID, actualBsEui, errToken := svc.ScheduleULTransmit(testutil.TestContext(), req, tenantID) // Assert: operation should succeed despite lookup error assert.Equal(t, expectedOpID, opID) @@ -269,7 +271,7 @@ func TestULService_ExplicitBsEui_SkipsPreferenceLookup(t *testing.T) { // Create service and call svc := NewULService(mockScheduler, mockStatus, log) - opID, actualBsEui, errToken := svc.ScheduleULTransmit(context.Background(), req, tenantID) + opID, actualBsEui, errToken := svc.ScheduleULTransmit(testutil.TestContext(), req, tenantID) // Assert results assert.Equal(t, expectedOpID, opID) @@ -311,7 +313,7 @@ func TestULService_NilStatusSvc_FallsBackDirectly(t *testing.T) { svc := NewULService(mockScheduler, nil, log) // Should not panic - opID, actualBsEui, errToken := svc.ScheduleULTransmit(context.Background(), req, tenantID) + opID, actualBsEui, errToken := svc.ScheduleULTransmit(testutil.TestContext(), req, tenantID) // Assert results assert.Equal(t, expectedOpID, opID) @@ -337,7 +339,7 @@ func TestULService_NilScheduler_ReturnsNotSupported(t *testing.T) { // Create service with nil scheduler svc := NewULService(nil, mockStatus, log) - opID, actualBsEui, errToken := svc.ScheduleULTransmit(context.Background(), req, 1) + opID, actualBsEui, errToken := svc.ScheduleULTransmit(testutil.TestContext(), req, 1) // Assert: should return error token assert.Equal(t, int64(0), opID) @@ -396,7 +398,7 @@ func TestULService_SchedulerError_MapsToToken(t *testing.T) { Return(int64(0), uint64(0), tc.schedulerErr) svc := NewULService(mockScheduler, nil, log) - _, _, errToken := svc.ScheduleULTransmit(context.Background(), req, 1) + _, _, errToken := svc.ScheduleULTransmit(testutil.TestContext(), req, 1) require.Equal(t, tc.expectedToken, errToken) }) diff --git a/KC-Core/pkg/bssci/cert_enforcement_test.go b/KC-Core/pkg/bssci/cert_enforcement_test.go index e0ba02d..b97106b 100644 --- a/KC-Core/pkg/bssci/cert_enforcement_test.go +++ b/KC-Core/pkg/bssci/cert_enforcement_test.go @@ -17,6 +17,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // makeEnforcementCert creates a real self-signed certificate and its PEM. @@ -94,7 +96,7 @@ func TestVerifyCertificateFingerprint_Match(t *testing.T) { TLSCertFingerprint: crypto.CertFingerprintSHA256(cert.Raw), } - assert.NoError(t, server.verifyCertificateFingerprint(context.Background(), session)) + assert.NoError(t, server.verifyCertificateFingerprint(testutil.TestContext(), session)) } // TestVerifyCertificateFingerprint_ForgedSameCNRejected: a different @@ -108,7 +110,7 @@ func TestVerifyCertificateFingerprint_ForgedSameCNRejected(t *testing.T) { TLSCertFingerprint: crypto.CertFingerprintSHA256(otherCert.Raw), } - assert.Error(t, server.verifyCertificateFingerprint(context.Background(), session), + assert.Error(t, server.verifyCertificateFingerprint(testutil.TestContext(), session), "a forged certificate sharing the CN must be rejected") } @@ -123,7 +125,7 @@ func TestVerifyCertificateFingerprint_BlankBackfillsFromStoredPEM(t *testing.T) TLSCertificate: pemData, } - require.NoError(t, server.verifyCertificateFingerprint(context.Background(), session)) + require.NoError(t, server.verifyCertificateFingerprint(testutil.TestContext(), session)) assert.Equal(t, 1, directory.backfillCalls, "the derived fingerprint is persisted") } @@ -139,7 +141,7 @@ func TestVerifyCertificateFingerprint_BlankStoredPEMOtherCertRejected(t *testing TLSCertificate: otherPEM, } - require.Error(t, server.verifyCertificateFingerprint(context.Background(), session)) + require.Error(t, server.verifyCertificateFingerprint(testutil.TestContext(), session)) assert.Zero(t, directory.backfillCalls, "a mismatch must never be persisted") } @@ -150,7 +152,7 @@ func TestVerifyCertificateFingerprint_BlankNoStoredCertRejected(t *testing.T) { server, session, _, _ := newEnforcementServer(t, directory) directory.station = RegisteredBaseStation{ID: 7, TenantID: 42} - assert.Error(t, server.verifyCertificateFingerprint(context.Background(), session)) + assert.Error(t, server.verifyCertificateFingerprint(testutil.TestContext(), session)) } // TestVerifyCertificateFingerprint_BackfillRaceReloadsAndCompares: a lost @@ -168,7 +170,7 @@ func TestVerifyCertificateFingerprint_BackfillRaceReloadsAndCompares(t *testing. TLSCertFingerprint: crypto.CertFingerprintSHA256(cert.Raw), } - require.NoError(t, server.verifyCertificateFingerprint(context.Background(), session)) + require.NoError(t, server.verifyCertificateFingerprint(testutil.TestContext(), session)) assert.Equal(t, 2, directory.getCalls, "the lost race reloads the row") // A concurrent writer that stored a DIFFERENT fingerprint rejects @@ -176,7 +178,7 @@ func TestVerifyCertificateFingerprint_BackfillRaceReloadsAndCompares(t *testing. server2, session2, _, pemData2 := newEnforcementServer(t, directory2) directory2.station = RegisteredBaseStation{ID: 7, TenantID: 42, TLSCertificate: pemData2} directory2.reloadStation = &RegisteredBaseStation{ID: 7, TenantID: 42, TLSCertFingerprint: "deadbeef"} - assert.Error(t, server2.verifyCertificateFingerprint(context.Background(), session2)) + assert.Error(t, server2.verifyCertificateFingerprint(testutil.TestContext(), session2)) } // TestVerifyCertificateFingerprint_LookupFailureRejects: an unreadable @@ -185,7 +187,7 @@ func TestVerifyCertificateFingerprint_LookupFailureRejects(t *testing.T) { directory := &fakeBSDirectory{getErr: errors.New("db down")} server, session, _, _ := newEnforcementServer(t, directory) - assert.Error(t, server.verifyCertificateFingerprint(context.Background(), session)) + assert.Error(t, server.verifyCertificateFingerprint(testutil.TestContext(), session)) } // TestConnectStrictMode_CertSubjectEUIMismatchRejected: in strict mode an diff --git a/KC-Core/pkg/bssci/connect_failure_test.go b/KC-Core/pkg/bssci/connect_failure_test.go index 08d9a67..a74c907 100644 --- a/KC-Core/pkg/bssci/connect_failure_test.go +++ b/KC-Core/pkg/bssci/connect_failure_test.go @@ -15,15 +15,11 @@ import ( // connection registration, exercising the activation compensation path. type failingRegistrationConnSvc struct{} -func (failingRegistrationConnSvc) GetBaseStation(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { +func (failingRegistrationConnSvc) GetBaseStationGlobal(_ context.Context, eui [8]byte) (*basestation.BaseStation, error) { return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Test BS"}, nil } -func (failingRegistrationConnSvc) GetBaseStationGlobal(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { - return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Test BS"}, nil -} - -func (failingRegistrationConnSvc) RegisterConnection(_ context.Context, _ *Session, _ *basestation.BaseStation, _ *basestation.ConnectionManager) error { +func (failingRegistrationConnSvc) RegisterConnection(_ context.Context, _ *Session, _ *basestation.BaseStation) error { return errors.New("registration failed") } diff --git a/KC-Core/pkg/bssci/connect_handler_test.go b/KC-Core/pkg/bssci/connect_handler_test.go index 30329b2..a3794c7 100644 --- a/KC-Core/pkg/bssci/connect_handler_test.go +++ b/KC-Core/pkg/bssci/connect_handler_test.go @@ -21,31 +21,13 @@ import ( // mockConnectionService implements ConnectionService for testing error catalog behavior type mockConnectionService struct { shouldFail bool - getCalled bool globalCalled bool registerCalled bool tenantID int64 // tenant ID returned by GetBaseStationGlobal (default 1) lastRegisterCtx context.Context } -func (m *mockConnectionService) GetBaseStation(_ context.Context, _ [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { - m.getCalled = true - if m.shouldFail { - return nil, errors.New("base station not found in database") - } - tid := m.tenantID - if tid == 0 { - tid = 1 - } - return &basestation.BaseStation{ - ID: 1, - TenantID: tid, - EUI: [8]byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF}, - Name: "Test Base Station", - }, nil -} - -func (m *mockConnectionService) GetBaseStationGlobal(_ context.Context, _ [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { +func (m *mockConnectionService) GetBaseStationGlobal(_ context.Context, _ [8]byte) (*basestation.BaseStation, error) { m.globalCalled = true if m.shouldFail { return nil, errors.New("base station not found in database") @@ -66,7 +48,7 @@ func (m *mockConnectionService) SaveSessionEncoding(_ context.Context, _ int64, return nil } -func (m *mockConnectionService) RegisterConnection(ctx context.Context, _ *bssci.Session, _ *basestation.BaseStation, _ *basestation.ConnectionManager) error { +func (m *mockConnectionService) RegisterConnection(ctx context.Context, _ *bssci.Session, _ *basestation.BaseStation) error { m.registerCalled = true m.lastRegisterCtx = ctx return nil diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index db0799b..8062a4f 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -154,18 +154,18 @@ type StatusService interface { ExtractQueueMetadata(session *Session, opId int64) (endpointEUI uint64, queueID int64, tenantID string) } -// ConnectionService wraps REAL basestation.ConnectionManager operations -// Preserves: GetBaseStation, UpdateConnectionStatus, EventRecorder -type ConnectionService interface { - // GetBaseStation via connectionMgr (tenant-scoped via context) - GetBaseStation(ctx context.Context, eui [8]byte, mgr *basestation.ConnectionManager) (*basestation.BaseStation, error) - +// BaseStationConnectionRegistry owns the live-connection operations the +// connect flow consumes: registration lookup across tenants (the tenant is +// not yet authenticated during the handshake) and live-connection +// registration. The concrete connection manager is captured by the adapter at +// construction, never passed per call. +type BaseStationConnectionRegistry interface { // GetBaseStationGlobal retrieves a base station by EUI across all tenants. - // Used during BSSCI connect handshake when tenant is not yet resolved. - GetBaseStationGlobal(ctx context.Context, eui [8]byte, mgr *basestation.ConnectionManager) (*basestation.BaseStation, error) + GetBaseStationGlobal(ctx context.Context, eui [8]byte) (*basestation.BaseStation, error) - // RegisterConnection updates basestation status - RegisterConnection(ctx context.Context, session *Session, baseStation *basestation.BaseStation, mgr *basestation.ConnectionManager) error + // RegisterConnection publishes the session's live connection and marks the + // base station online. + RegisterConnection(ctx context.Context, session *Session, baseStation *basestation.BaseStation) error } // CertificateIdentity is the tenant/organization identity resolved from a diff --git a/KC-Core/pkg/bssci/detach_integration_test.go b/KC-Core/pkg/bssci/detach_integration_test.go index fa30b7d..6226470 100644 --- a/KC-Core/pkg/bssci/detach_integration_test.go +++ b/KC-Core/pkg/bssci/detach_integration_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" @@ -21,6 +21,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestDetachThreeWayHandshake validates that the detach → detRsp → detCmp flow @@ -181,7 +183,7 @@ func TestDetachAuditEventRecorded(t *testing.T) { type detachTestEnv struct { server *Server session *Session - conn *testutil.TestConn + conn *bsscitest.TestConn repo *fakeEndpointRepo events *recordingEventStore } @@ -220,7 +222,7 @@ func newDetachTestEnv(t *testing.T, cfg *Config, endpoint *models.EndPoint) *det t.Fatalf("endpoint repository not configured") } - conn := &testutil.TestConn{Encoding: "json"} + conn := &bsscitest.TestConn{Encoding: "json"} // Default tenant to 1 if endpoint is nil (unknown endpoint test case) sessionTenant := int64(1) if endpoint != nil { @@ -896,7 +898,7 @@ func TestFakeEndpointRepoBasics(t *testing.T) { endpoint := buildTestEndpoint(knownEui, tenant100) repo := newFakeEndpointRepo(endpoint) - ctx := context.Background() + ctx := testutil.TestContext() euiBytes := make([]byte, 8) // Test 1: Same-tenant lookup should succeed diff --git a/KC-Core/pkg/bssci/detach_propagate_complete_test.go b/KC-Core/pkg/bssci/detach_propagate_complete_test.go index 1f3ecab..9ed4a64 100644 --- a/KC-Core/pkg/bssci/detach_propagate_complete_test.go +++ b/KC-Core/pkg/bssci/detach_propagate_complete_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + bssci "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" @@ -21,9 +23,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vmihailenco/msgpack/v5" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" ) // --- Mock Implementations for Detach Propagate Complete Integration Tests --- @@ -846,9 +845,9 @@ func TestSendDetachPropagateComplete_SendFailure(t *testing.T) { storageImpl := &detPrpCapturingStorage{miotyMessages: msgRepo, endpointRepo: endpointRepo, pendingOps: pendingOps} eventStore := &detPrpCapturingEventStore{} - // Zap observer logger so the test can assert the failure log token. - core, recorded := observer.New(zapcore.DebugLevel) - testLogger := logger.FromZap(zap.New(core)) + // Recording logger so the test can assert the failure log token. + recorded := bsscitest.NewRecordingLogger() + testLogger := recorded sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(testLogger, eventStore) server := bssci.NewTestServer(testLogger, storageImpl, eventStore, testTenantID, @@ -897,8 +896,8 @@ func TestSendDetachPropagateComplete_SendFailure(t *testing.T) { // Log assertion: the handler must emit LogBSSCIFailedToSendDetachPropagateComplete at error level. var found bool - for _, entry := range recorded.All() { - if entry.Level == zapcore.ErrorLevel && entry.Message == bssci.LogBSSCIFailedToSendDetachPropagateComplete { + for _, entry := range recorded.AllAtLeast("DEBUG") { + if entry.Level == "ERROR" && entry.Message == bssci.LogBSSCIFailedToSendDetachPropagateComplete { found = true break } diff --git a/KC-Core/pkg/bssci/detach_validation_test.go b/KC-Core/pkg/bssci/detach_validation_test.go index 203959a..536b52c 100644 --- a/KC-Core/pkg/bssci/detach_validation_test.go +++ b/KC-Core/pkg/bssci/detach_validation_test.go @@ -4,12 +4,11 @@ import ( "context" "testing" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/zap" - "go.uber.org/zap/zaptest/observer" ) // mockDetachValidator implements DetachSignatureValidator for testing @@ -51,8 +50,8 @@ func TestServer_DetachComplete_InvalidSignature_LogsTenantMetadata(t *testing.T) }, nil) // nil endpoint = unknown endpoint // Replace logger with observed logger to capture log output - observedCore, observedLogs := observer.New(zap.WarnLevel) - env.server.logger = logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures WARN+ + env.server.logger = observedLogs // Install mock validator env.server.detachValidator = mockValidator @@ -83,14 +82,14 @@ func TestServer_DetachComplete_InvalidSignature_LogsTenantMetadata(t *testing.T) assert.Equal(t, expectedMsg, errorMessage, "Error message should match catalog token") // Assert: Log contains tenant metadata - allLogs := observedLogs.All() + allLogs := observedLogs.AllAtLeast("WARN") foundLog := false for _, logEntry := range allLogs { if logEntry.Message == "Unknown endpoint detach signature invalid" { foundLog = true // Verify log context fields - fields := logEntry.ContextMap() + fields := logEntry.FieldMap() assert.Equal(t, epEui, fields["epEui"], "Log should contain epEui") assert.Equal(t, int64(tenantID), fields["tenant_id"], "Log should contain tenant_id") assert.Equal(t, int64(ownerID), fields["owner_tenant_id"], "Log should contain owner_tenant_id") diff --git a/KC-Core/pkg/bssci/dlrx_expiry_sweep_test.go b/KC-Core/pkg/bssci/dlrx_expiry_sweep_test.go new file mode 100644 index 0000000..f29d85e --- /dev/null +++ b/KC-Core/pkg/bssci/dlrx_expiry_sweep_test.go @@ -0,0 +1,84 @@ +package bssci + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// expiryDLRXRepo captures the cutoff passed to the expiry sweep. +type expiryDLRXRepo struct { + interfaces.DLRXStatusRepository + cutoff time.Time + calls int + expired int64 + expireErr error +} + +func (r *expiryDLRXRepo) ExpireDLRXStatusQuery(_ context.Context, cutoff time.Time) (int64, error) { + r.calls++ + r.cutoff = cutoff + return r.expired, r.expireErr +} + +// expiryStorage wires the capturing repository into the stub storage. +type expiryStorage struct { + *stubStorage + dlrx *expiryDLRXRepo +} + +func (s *expiryStorage) DLRXStatus() interfaces.DLRXStatusRepository { return s.dlrx } + +func newExpiryFixture(t *testing.T, queryTimeout time.Duration) (*Server, *expiryDLRXRepo) { + t.Helper() + log := newRecordingLogger() + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := CreateTestServices(log, nil) + dlrx := &expiryDLRXRepo{} + storage := &expiryStorage{stubStorage: newStubStorage(), dlrx: dlrx} + server := NewTestServer(log, storage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) + server.config = &Config{MessageEncoding: EncodingJSON, DLRXQueryTimeout: queryTimeout} + return server, dlrx +} + +// TestSweepExpiredDLRXQueries_CutoffArithmetic: the sweep expires queries +// older than the configured timeout relative to the supplied clock. +func TestSweepExpiredDLRXQueries_CutoffArithmetic(t *testing.T) { + server, dlrx := newExpiryFixture(t, 5*time.Minute) + + now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC) + server.sweepExpiredDLRXQueries(now) + + require.Equal(t, 1, dlrx.calls) + assert.Equal(t, now.Add(-5*time.Minute), dlrx.cutoff, + "cutoff is the sweep clock minus the configured dlrx query timeout") +} + +// TestSweepExpiredDLRXQueries_DefaultTimeout: an unset timeout falls back to +// the package default. +func TestSweepExpiredDLRXQueries_DefaultTimeout(t *testing.T) { + server, dlrx := newExpiryFixture(t, 0) + + now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC) + server.sweepExpiredDLRXQueries(now) + + require.Equal(t, 1, dlrx.calls) + assert.Equal(t, now.Add(-defaultDLRXQueryTimeout), dlrx.cutoff) +} + +// TestSweepExpiredDLRXQueries_StoreFailureIsNonFatal: a store failure is +// logged and the sweep returns without panicking, leaving the next tick to +// retry. +func TestSweepExpiredDLRXQueries_StoreFailureIsNonFatal(t *testing.T) { + server, dlrx := newExpiryFixture(t, time.Minute) + dlrx.expireErr = errors.New("db down") + + server.sweepExpiredDLRXQueries(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)) + + require.Equal(t, 1, dlrx.calls) +} diff --git a/KC-Core/pkg/bssci/downlink_handlers.go b/KC-Core/pkg/bssci/downlink_handlers.go index f053460..91f8de3 100644 --- a/KC-Core/pkg/bssci/downlink_handlers.go +++ b/KC-Core/pkg/bssci/downlink_handlers.go @@ -334,9 +334,9 @@ func (s *Server) SendDLDataQueue(sessionID string, epEui uint64, payloads [][]by // the counter once for the whole pair, persist the pending records, then // write the frames. IDs are never rolled back. The query ID is allocated // first so its pending row precedes the queue row in reissue order. - var qryOpId int64 + var qryOpID int64 if dlRxStatQry { - qryOpId = session.NextScOpID() + qryOpID = session.NextScOpID() } opId := session.NextScOpID() if err := s.sessionSvc.UpdateSessionCounters(s.sessionContext(session), session); err != nil { @@ -449,21 +449,21 @@ func (s *Server) SendDLDataQueue(sessionID string, epEui uint64, payloads [][]by if dlRxStatQry { qryMsg = map[string]interface{}{ "command": mioty.CmdDLRxStatusQuery, - "opId": qryOpId, + "opId": qryOpID, "epEui": epEui, } - if err := s.persistDLRXQueryCorrelation(session, qryOpId, epEui, euiBytes); err != nil { + if err := s.persistDLRXQueryCorrelation(session, qryOpID, epEui, euiBytes); err != nil { return err } pair := []*PendingOperation{ - s.buildPendingOperation(session, qryOpId, mioty.CmdDLRxStatusQuery, qryMsg, euiBytes, nil), + s.buildPendingOperation(session, qryOpID, mioty.CmdDLRxStatusQuery, qryMsg, euiBytes, nil), s.buildPendingOperation(session, opId, mioty.CmdDLDataQueue, msg, euiBytes, metadata), } if err := s.persistPendingOperationBatch(session, pair); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLDataQueOperation, "sessionID", sessionID, "opId", opId, - "qryOpId", qryOpId, + "qryOpID", qryOpID, "error", err) return err } @@ -484,14 +484,14 @@ func (s *Server) SendDLDataQueue(sessionID string, epEui uint64, payloads [][]by if errors.Is(err, ErrAmbiguousWrite) { // Both recovery rows are preserved; resume reissues the pair // with its original IDs. - s.closeTransportAfterWriteFailure(session, qryOpId, err) + s.closeTransportAfterWriteFailure(session, qryOpID, err) } else { // Nothing reached the wire - remove both recovery rows. - for _, cleanupOpId := range []int64{qryOpId, opId} { - if cleanupErr := s.removePendingOperation(session, cleanupOpId); cleanupErr != nil { + for _, cleanupOpID := range []int64{qryOpID, opId} { + if cleanupErr := s.removePendingOperation(session, cleanupOpID); cleanupErr != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToClearPersistedPendingOperation, "sessionID", session.DbSessionID, - "opId", cleanupOpId, + "opId", cleanupOpID, "error", cleanupErr) } } @@ -743,7 +743,7 @@ func (s *Server) handleDLDataRevokeResponse(_ *Server, session *Session, msg *Me } // Fallback for non-catalog errors (shouldn't happen after service refactor) - s.logger.ErrorContext(s.sessionContext(session), "Unexpected non-catalog error from ProcessRevokeResponse", + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIUnexpectedRevokeResponseError, "error", err, "opId", msg.OpId) if sendErr := s.sendErrorReplacingOperation(session, msg.OpId, POSIX_EIO, ResolveErrorMessage(errDatabaseUpdateFailed)); sendErr != nil { @@ -1046,7 +1046,7 @@ func (s *Server) handleDLRXStatus(_ *Server, session *Session, msg *Message, dat } // Validate SNR is finite and within plausible range per BSSCI §5.15.1 if errToken := validateFiniteFloat(dlRxSnr, mioty.DLRxSnrMinDB, mioty.DLRxSnrMaxDB); errToken != "" { - s.logger.WarnContext(s.sessionContext(session), "DL RX status SNR validation failed", + s.logger.WarnContext(s.sessionContext(session), LogBSSCIDLRXStatusSNRValidationFailed, "epEui", dlRxStatus.EpEui, "dlRxSnr", dlRxSnr, "error", errToken) @@ -1067,7 +1067,7 @@ func (s *Server) handleDLRXStatus(_ *Server, session *Session, msg *Message, dat } // Validate RSSI is finite and within plausible range per BSSCI §5.15.1 if errToken := validateFiniteFloat(dlRxRssi, mioty.DLRxRssiMinDBm, mioty.DLRxRssiMaxDBm); errToken != "" { - s.logger.WarnContext(s.sessionContext(session), "DL RX status RSSI validation failed", + s.logger.WarnContext(s.sessionContext(session), LogBSSCIDLRXStatusRSSIValidationFailed, "epEui", dlRxStatus.EpEui, "dlRxRssi", dlRxRssi, "error", errToken) @@ -1092,7 +1092,7 @@ func (s *Server) handleDLRXStatus(_ *Server, session *Session, msg *Message, dat // Resolve endpoint owner tenant for roaming scenarios tenantID, err := s.resolveEndpointTenantID(ctx, session, dlRxStatus.EpEui) if err != nil { - s.logger.ErrorContext(ctx, "Failed to resolve endpoint tenant for DL RX status", + s.logger.ErrorContext(ctx, LogBSSCIFailedToResolveEndpointTenantForDLRXStatus, "error", err, "epEui", dlRxStatus.EpEui) if err := s.sendError(session, msg.OpId, POSIX_EIO, ResolveErrorMessage(errFailedToPersistDLRXStatus)); err != nil { @@ -1139,7 +1139,7 @@ func (s *Server) handleDLRXStatus(_ *Server, session *Session, msg *Message, dat msg.OpId, // Audit: BS opId (different namespace) ) if err != nil { - s.logger.ErrorContext(ownerCtx, "Failed to correlate DL RX query", + s.logger.ErrorContext(ownerCtx, LogBSSCIFailedToCorrelateDLRXQuery, "epEui", dlRxStatus.EpEui, "opId", msg.OpId, "error", err) @@ -1147,7 +1147,7 @@ func (s *Server) handleDLRXStatus(_ *Server, session *Session, msg *Message, dat } if !found { - s.logger.WarnContext(ownerCtx, "Unsolicited DL RX status (no pending query)", + s.logger.WarnContext(ownerCtx, LogBSSCIUnsolicitedDLRXStatus, "epEui", dlRxStatus.EpEui, "tenant", tenantID, "bsEui", session.BaseStationEUI) @@ -1160,7 +1160,7 @@ func (s *Server) handleDLRXStatus(_ *Server, session *Session, msg *Message, dat // Resolve endpoint owner's organization UUID (BSSCI §5.15) epOwnerOrgUUID, err := s.resolveOwnerOrgUUID(ownerCtx, tenantID, epEuiBytes) if err != nil { - s.logger.ErrorContext(ownerCtx, "Failed to resolve endpoint owner org for DL RX status persistence", + s.logger.ErrorContext(ownerCtx, LogBSSCIFailedToResolveEndpointOwnerOrgForDLRXStatus, "error", err, "tenant", tenantID, "epEui", dlRxStatus.EpEui) diff --git a/KC-Core/pkg/bssci/epstatus_forward_test.go b/KC-Core/pkg/bssci/epstatus_forward_test.go index 064e561..a1881e1 100644 --- a/KC-Core/pkg/bssci/epstatus_forward_test.go +++ b/KC-Core/pkg/bssci/epstatus_forward_test.go @@ -8,7 +8,6 @@ package bssci import ( - "context" "errors" "testing" @@ -17,6 +16,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" // Numeric4, Subpackets types "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================= @@ -42,7 +43,7 @@ func TestMockSCACIEPStatusBroadcaster_RecordsCalls(t *testing.T) { EpEui: 0x1234567890ABCDEF, EpStatus: pkgmioty.EPStatusAttached, } - err := mock.BroadcastEPStatus(context.Background(), 42, data1) + err := mock.BroadcastEPStatus(testutil.TestContext(), 42, data1) require.NoError(t, err) // Verify first call recorded @@ -58,7 +59,7 @@ func TestMockSCACIEPStatusBroadcaster_RecordsCalls(t *testing.T) { EpEui: 0xAABBCCDDEEFF0011, EpStatus: pkgmioty.EPStatusDetached, } - err = mock.BroadcastEPStatus(context.Background(), 99, data2) + err = mock.BroadcastEPStatus(testutil.TestContext(), 99, data2) require.NoError(t, err) // Verify second call @@ -92,7 +93,7 @@ func TestMockSCACIEPStatusBroadcaster_ReturnsConfiguredError(t *testing.T) { EpEui: 0x1234567890ABCDEF, EpStatus: pkgmioty.EPStatusAttached, } - err := mock.BroadcastEPStatus(context.Background(), 1, data) + err := mock.BroadcastEPStatus(testutil.TestContext(), 1, data) // Error should be returned require.Error(t, err) @@ -107,8 +108,8 @@ func TestMockSCACIEPStatusBroadcaster_Reset(t *testing.T) { // Add some calls data := &EPStatusData{EpEui: 0x1234, EpStatus: pkgmioty.EPStatusAttached} - _ = mock.BroadcastEPStatus(context.Background(), 1, data) - _ = mock.BroadcastEPStatus(context.Background(), 2, data) + _ = mock.BroadcastEPStatus(testutil.TestContext(), 1, data) + _ = mock.BroadcastEPStatus(testutil.TestContext(), 2, data) assert.Equal(t, 2, mock.CallCount()) @@ -293,7 +294,7 @@ func TestHandleAttachComplete_BroadcastsEPStatus(t *testing.T) { "endpointID": int64(1001), }, } - err := statusSvc.RecordPendingOperation(context.Background(), session, opID, pendingOp, tenantID) + err := statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, tenantID) require.NoError(t, err, "failed to seed pending operation") // Prepare WaitGroup before calling handler (goroutine will decrement) @@ -377,7 +378,7 @@ func TestHandleDetachComplete_BroadcastsEPStatus(t *testing.T) { "endpointID": float64(2001), // Optional but useful }, } - err := statusSvc.RecordPendingOperation(context.Background(), session, opID, pendingOp, tenantID) + err := statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, tenantID) require.NoError(t, err, "failed to seed pending operation") // Prepare WaitGroup before calling handler (goroutine will decrement) @@ -443,7 +444,7 @@ func TestHandleAttachComplete_NilBroadcaster_NoPanic(t *testing.T) { "endpointID": int64(3001), }, } - err := statusSvc.RecordPendingOperation(context.Background(), session, opID, pendingOp, tenantID) + err := statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, tenantID) require.NoError(t, err) // Action: Call handleAttachComplete - should not panic @@ -512,7 +513,7 @@ func TestHandleAttachComplete_BroadcastsEPStatus_FullTelemetry(t *testing.T) { "endpointID": int64(5001), }, } - err := statusSvc.RecordPendingOperation(context.Background(), session, opID, pendingOp, tenantID) + err := statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, tenantID) require.NoError(t, err, "failed to seed pending operation") // Prepare WaitGroup before calling handler @@ -614,7 +615,7 @@ func TestHandleDetachComplete_BroadcastsEPStatus_SignEqSnrFallback(t *testing.T) "endpointID": float64(6001), }, } - err := statusSvc.RecordPendingOperation(context.Background(), session, opID, pendingOp, tenantID) + err := statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, tenantID) require.NoError(t, err, "failed to seed pending operation") // Prepare WaitGroup before calling handler @@ -691,7 +692,7 @@ func TestHandleDetachComplete_NilBroadcaster_NoPanic(t *testing.T) { "endpointID": int64(4001), }, } - err := statusSvc.RecordPendingOperation(context.Background(), session, opID, pendingOp, tenantID) + err := statusSvc.RecordPendingOperation(testutil.TestContext(), session, opID, pendingOp, tenantID) require.NoError(t, err) // Action: Call handleDetachComplete - should not panic diff --git a/KC-Core/pkg/bssci/log_messages.go b/KC-Core/pkg/bssci/log_messages.go index 39ead2d..a897933 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -1173,4 +1173,30 @@ const ( LogBSSCIUplinkIngestFailed = "Uplink ingest failed" // LogBSSCIUsingDevelopmentSoftwareVersionInConnectResponse is logged when using development software version in ConnectResponse. LogBSSCIUsingDevelopmentSoftwareVersionInConnectResponse = "Using development software version in ConnectResponse" + // LogBSSCIResumeRejectedVersionIncompatible is logged when a resume is rejected because the persisted negotiated version is incompatible with the selected version. + LogBSSCIResumeRejectedVersionIncompatible = "Resume rejected: persisted negotiated version incompatible with the selected version" + // LogBSSCIResumeRejectedBsOpIDBeyondPersisted is logged when a resume is rejected because the required BS operation ID is beyond the persisted state. + LogBSSCIResumeRejectedBsOpIDBeyondPersisted = "Resume rejected: required BS operation ID beyond persisted state" + // LogBSSCIResumeRejectedScOpIDBeyondIssued is logged when a resume is rejected because the claimed SC operation ID is beyond the issued state. + LogBSSCIResumeRejectedScOpIDBeyondIssued = "Resume rejected: claimed SC operation ID beyond issued state" + // LogBSSCIResumeAcceptedStaleBsCounter is logged when a resume is accepted with a stale BS counter. + LogBSSCIResumeAcceptedStaleBsCounter = "Resume accepted with stale BS counter (SC is authoritative)" + // LogBSSCIFailedToMarshalPendingOperation is logged when a pending operation cannot be marshaled for persistence. + LogBSSCIFailedToMarshalPendingOperation = "Failed to marshal pending operation" + // LogBSSCIFailedToMarshalPendingOperationMetadata is logged when pending operation metadata cannot be marshaled for persistence. + LogBSSCIFailedToMarshalPendingOperationMetadata = "Failed to marshal pending operation metadata" + // LogBSSCIUnexpectedRevokeResponseError is logged when ProcessRevokeResponse returns a non-catalog error. + LogBSSCIUnexpectedRevokeResponseError = "Unexpected non-catalog error from ProcessRevokeResponse" + // LogBSSCIDLRXStatusSNRValidationFailed is logged when the dlRxSnr value in a DL RX status report fails validation. + LogBSSCIDLRXStatusSNRValidationFailed = "DL RX status SNR validation failed" + // LogBSSCIDLRXStatusRSSIValidationFailed is logged when the dlRxRssi value in a DL RX status report fails validation. + LogBSSCIDLRXStatusRSSIValidationFailed = "DL RX status RSSI validation failed" + // LogBSSCIFailedToResolveEndpointTenantForDLRXStatus is logged when the endpoint tenant cannot be resolved for a DL RX status report. + LogBSSCIFailedToResolveEndpointTenantForDLRXStatus = "Failed to resolve endpoint tenant for DL RX status" + // LogBSSCIFailedToCorrelateDLRXQuery is logged when a DL RX status report cannot be correlated with its pending query. + LogBSSCIFailedToCorrelateDLRXQuery = "Failed to correlate DL RX query" + // LogBSSCIUnsolicitedDLRXStatus is logged when a DL RX status report arrives without a pending query. + LogBSSCIUnsolicitedDLRXStatus = "Unsolicited DL RX status (no pending query)" + // LogBSSCIFailedToResolveEndpointOwnerOrgForDLRXStatus is logged when the endpoint owner organization cannot be resolved for DL RX status persistence. + LogBSSCIFailedToResolveEndpointOwnerOrgForDLRXStatus = "Failed to resolve endpoint owner org for DL RX status persistence" ) diff --git a/KC-Core/pkg/bssci/message_normalization_gate_test.go b/KC-Core/pkg/bssci/message_normalization_gate_test.go index 0856661..53ccbf5 100644 --- a/KC-Core/pkg/bssci/message_normalization_gate_test.go +++ b/KC-Core/pkg/bssci/message_normalization_gate_test.go @@ -3,8 +3,6 @@ package bssci import ( "testing" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/stretchr/testify/assert" ) @@ -16,8 +14,6 @@ import ( // Normalizing our own SC→BS responses would incorrectly validate server-generated payloads, // which was the root cause of Issue #3's normalization regression. func TestNormalizationOnlyForBStoSCCommands(t *testing.T) { - testLogger := logger.NewNop() - tests := []struct { command string shouldNormalize bool @@ -48,164 +44,16 @@ func TestNormalizationOnlyForBStoSCCommands(t *testing.T) { for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { - // Track normalization calls - var spyCalled bool - var spyCommand string - var spyShouldNormalize bool - - // Create server with StatusService - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := CreateTestServices(testLogger, nil) - server := NewTestServer(testLogger, mockStorage, nil, 1, - sessionSvc, downlinkSvc, statusSvc, connectionSvc, - broadcaster, queueSerializer, auditLogger, tenantResolver) - - // Inject test spy - server.testNormalizationSpy = func(cmd string, normalized bool) { - spyCalled = true - spyCommand = cmd - spyShouldNormalize = normalized - } - - // Create minimal session with mock connection - session := &Session{ - ProtocolSessionState: ProtocolSessionState{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", - HandshakeComplete: true, - }, - Conn: &testutil.TestConn{}, - } - - // Build valid minimal payload for this command - msg := &Message{ - Command: tt.command, - OpId: int64(100), - } - data := buildMinimalPayloadForCommand(tt.command) - - // Call handleMessage (will invoke spy before normalization) - _ = server.handleMessage(session, msg, data) - - // Verify spy was called with correct parameters - assert.True(t, spyCalled, "testNormalizationSpy should be called") - assert.Equal(t, tt.command, spyCommand, "Spy should receive correct command") - assert.Equal(t, tt.shouldNormalize, spyShouldNormalize, - "Command %s (%s) normalize decision incorrect", tt.command, tt.direction) + assert.Equal(t, tt.shouldNormalize, shouldNormalizeCommand(tt.command), + "Command %s (%s) normalize decision incorrect: %s", tt.command, tt.direction, tt.description) }) } } -// buildMinimalPayloadForCommand creates a minimal valid payload for testing normalization gate -// This ensures handleMessage doesn't crash on normalization errors before spy is called -func buildMinimalPayloadForCommand(command string) map[string]interface{} { - // Base payload with command and opId - payload := map[string]interface{}{ - "command": command, - "opId": int64(100), - } - - // Add command-specific required fields - switch command { - case mioty.CmdAttach: - payload["epEui"] = uint64(TestEpEui01) - payload["rxTime"] = int64(1234567890) - payload["attachCnt"] = uint32(1) - payload["snr"] = float64(10.0) - payload["rssi"] = float64(-80.0) - payload["nonce"] = []byte{0x01, 0x02, 0x03, 0x04} - payload["sign"] = []byte{0x05, 0x06, 0x07, 0x08} - payload["dualChan"] = false - payload["repetition"] = false - payload["wideCarrOff"] = false - payload["longBlkDist"] = false - case mioty.CmdDetach: - payload["epEui"] = uint64(TestEpEui01) - payload["rxTime"] = int64(1234567890) - payload["packetCnt"] = uint32(42) - payload["snr"] = float64(10.0) - payload["rssi"] = float64(-80.0) - payload["sign"] = []byte{0x01, 0x02, 0x03, 0x04} - case mioty.CmdULData: - payload["epEui"] = uint64(TestEpEui01) - payload["rxTime"] = int64(1234567890) - payload["packetCnt"] = uint32(42) - payload["userData"] = []byte{0xAA, 0xBB} - payload["snr"] = float64(10.0) - payload["rssi"] = float64(-80.0) - payload["dlOpen"] = false - payload["responseExp"] = false - payload["dlAck"] = false - case mioty.CmdDLDataResult: - payload["epEui"] = uint64(TestEpEui01) - payload["queId"] = uint64(12345) - payload["result"] = "sent" - payload["txTime"] = int64(1234567890) - payload["packetCnt"] = uint32(42) - case mioty.CmdConnect: - payload["version"] = "1.0.0" - payload["bsEui"] = uint64(TestBsEui01) - payload["bidi"] = true - payload["snBsUuid"] = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - default: - // For commands without strict normalization requirements (responses, etc.) - // Just return base payload - they won't be normalized anyway - } - - return payload -} - -// TestNormalizationSkipsUnknownCommands verifies safe fallback behavior: -// Commands not in CommandDirectionMap should default to skipping normalization. +// TestNormalizationSkipsUnknownCommands verifies commands absent from +// CommandDirectionMap default to NOT normalizing (safe fallback). // This prevents accidentally normalizing future protocol extensions or vendor-specific commands. func TestNormalizationSkipsUnknownCommands(t *testing.T) { - testLogger := logger.NewNop() - - // Track normalization calls - var spyCalled bool - var spyCommand string - var spyShouldNormalize bool - - // Create server with StatusService - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := CreateTestServices(testLogger, nil) - server := NewTestServer(testLogger, mockStorage, nil, 1, - sessionSvc, downlinkSvc, statusSvc, connectionSvc, - broadcaster, queueSerializer, auditLogger, tenantResolver) - - // Inject test spy - server.testNormalizationSpy = func(cmd string, normalized bool) { - spyCalled = true - spyCommand = cmd - spyShouldNormalize = normalized - } - - // Create minimal session - session := &Session{ - ProtocolSessionState: ProtocolSessionState{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", - HandshakeComplete: true, - }, - Conn: &testutil.TestConn{}, - } - - // Test with unknown command - unknownCmd := "unknownFutureCommand" - msg := &Message{ - Command: unknownCmd, - OpId: int64(100), - } - data := map[string]interface{}{ - "command": unknownCmd, - "opId": int64(100), - } - - // Call handleMessage - _ = server.handleMessage(session, msg, data) - - // Verify unknown commands default to NOT normalizing (safe fallback) - assert.True(t, spyCalled, "testNormalizationSpy should be called") - assert.Equal(t, unknownCmd, spyCommand, "Spy should receive unknown command") - assert.False(t, spyShouldNormalize, "Unknown commands should default to skipping normalization (safe fallback)") + assert.False(t, shouldNormalizeCommand("unknownFutureCommand"), + "Unknown commands should default to skipping normalization (safe fallback)") } diff --git a/KC-Core/pkg/bssci/message_normalizer_test.go b/KC-Core/pkg/bssci/message_normalizer_test.go index 141a4ad..fda7360 100644 --- a/KC-Core/pkg/bssci/message_normalizer_test.go +++ b/KC-Core/pkg/bssci/message_normalizer_test.go @@ -1,7 +1,6 @@ package bssci import ( - "context" "math" "reflect" "testing" @@ -10,13 +9,15 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vmihailenco/msgpack/v5" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestDetachEqSnrDefaulting verifies BSSCI §5.7.1 eqSnr defaulting behavior // When eqSnr is absent, it should default to snr value (special case for detach) func TestDetachEqSnrDefaulting(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() tests := []struct { name string @@ -90,7 +91,7 @@ func TestDetachEqSnrDefaulting(t *testing.T) { // rxDuration=0 must be preserved as explicit value, not treated as absent func TestDetachRxDurationZeroPreservation(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() tests := []struct { name string @@ -169,7 +170,7 @@ func TestDetachRxDurationZeroPreservation(t *testing.T) { // TestDetachProfileEmptyStringPreservation verifies profile="" is valid and preserved func TestDetachProfileEmptyStringPreservation(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() tests := []struct { name string @@ -242,7 +243,7 @@ func TestDetachProfileEmptyStringPreservation(t *testing.T) { // TestDetachSignatureByteArrayValidation verifies signature field byte array handling func TestDetachSignatureByteArrayValidation(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() tests := []struct { name string @@ -309,7 +310,7 @@ func TestDetachSignatureByteArrayValidation(t *testing.T) { // Unknown fields should be logged (WARN) and dropped without failing normalization func TestDetachUnknownFieldDropping(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() inputData := map[string]interface{}{ "command": "det", @@ -346,7 +347,7 @@ func TestDetachUnknownFieldDropping(t *testing.T) { // TestDetachMandatoryFieldValidation verifies mandatory field enforcement func TestDetachMandatoryFieldValidation(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() tests := []struct { name string @@ -613,7 +614,7 @@ func TestMsgpackDecoderProducesUint64ForUnsigned(t *testing.T) { // uint64 from the msgpack decoder, and normalization must coerce it to uint32 without error. func TestNormalizeULData_PacketCntAsUint64(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() data := map[string]interface{}{ "command": "ulData", diff --git a/KC-Core/pkg/bssci/mqtt_publish_test.go b/KC-Core/pkg/bssci/mqtt_publish_test.go index aa5fab6..cfd31d8 100644 --- a/KC-Core/pkg/bssci/mqtt_publish_test.go +++ b/KC-Core/pkg/bssci/mqtt_publish_test.go @@ -8,12 +8,14 @@ import ( "testing" "time" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockMQTTEventPublisher records calls to MQTTEventPublisher methods. @@ -255,7 +257,7 @@ func TestHandleAttachComplete_NilMQTTPublisher_NoPanic(t *testing.T) { "epEui": int64(0x70B3D59CD00009E6), }, } - err := server.statusSvc.RecordPendingOperation(context.Background(), session, 1001, pendingOp, 42) + err := server.statusSvc.RecordPendingOperation(testutil.TestContext(), session, 1001, pendingOp, 42) require.NoError(t, err) msg := &Message{Command: mioty.CmdAttachComplete, OpId: 1001} @@ -287,7 +289,7 @@ func TestHandleDetachComplete_NilMQTTPublisher_NoPanic(t *testing.T) { "epEui": int64(0x70B3D59CD00009E6), }, } - err := server.statusSvc.RecordPendingOperation(context.Background(), session, 2001, pendingOp, 42) + err := server.statusSvc.RecordPendingOperation(testutil.TestContext(), session, 2001, pendingOp, 42) require.NoError(t, err) msg := &Message{Command: mioty.CmdDetachComplete, OpId: 2001} @@ -330,7 +332,7 @@ func TestHandleAttachComplete_PublishesMQTTWithOwnerOrg(t *testing.T) { "endpointTenantID": int64(99), }, } - err := server.statusSvc.RecordPendingOperation(context.Background(), session, 1001, pendingOp, 42) + err := server.statusSvc.RecordPendingOperation(testutil.TestContext(), session, 1001, pendingOp, 42) require.NoError(t, err) syncMock.ExpectCall(1) @@ -381,7 +383,7 @@ func TestHandleAttachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { "endpointTenantID": int64(999), // no mapping for this tenant }, } - err := server.statusSvc.RecordPendingOperation(context.Background(), session, 1002, pendingOp, 42) + err := server.statusSvc.RecordPendingOperation(testutil.TestContext(), session, 1002, pendingOp, 42) require.NoError(t, err) msg := &Message{Command: mioty.CmdAttachComplete, OpId: 1002} @@ -422,7 +424,7 @@ func TestHandleDetachComplete_PublishesMQTTWithTypedMetaOrg(t *testing.T) { "orgUuid": ownerOrg.String(), }, } - err := server.statusSvc.RecordPendingOperation(context.Background(), session, 2001, pendingOp, 42) + err := server.statusSvc.RecordPendingOperation(testutil.TestContext(), session, 2001, pendingOp, 42) require.NoError(t, err) syncMock.ExpectCall(1) @@ -468,7 +470,7 @@ func TestHandleDetachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { // No orgUuid in metadata }, } - err := server.statusSvc.RecordPendingOperation(context.Background(), session, 2002, pendingOp, 42) + err := server.statusSvc.RecordPendingOperation(testutil.TestContext(), session, 2002, pendingOp, 42) require.NoError(t, err) msg := &Message{Command: mioty.CmdDetachComplete, OpId: 2002} @@ -533,7 +535,7 @@ func TestHandleULData_PublishesMQTTUplink(t *testing.T) { DbSessionID: 1, Encoding: EncodingJSON, }, - Conn: &testutil.TestConn{Encoding: "json"}, + Conn: &bsscitest.TestConn{Encoding: "json"}, } // Build UL data map with required fields @@ -598,7 +600,7 @@ func TestHandleULData_OrgUnresolved_SkipsPublish(t *testing.T) { DbSessionID: 1, Encoding: EncodingJSON, }, - Conn: &testutil.TestConn{Encoding: "json"}, + Conn: &bsscitest.TestConn{Encoding: "json"}, } data := map[string]interface{}{ @@ -652,7 +654,7 @@ func TestHandleDLDataResult_PublishesMQTTDLResult(t *testing.T) { DbSessionID: 1, Encoding: EncodingJSON, }, - Conn: &testutil.TestConn{Encoding: "json"}, + Conn: &bsscitest.TestConn{Encoding: "json"}, } // Build DL result data map (handleDLDataResult uses type assertions directly) @@ -706,7 +708,7 @@ func TestHandleDLDataResult_OrgUnresolved_SkipsPublish(t *testing.T) { DbSessionID: 1, Encoding: EncodingJSON, }, - Conn: &testutil.TestConn{Encoding: "json"}, + Conn: &bsscitest.TestConn{Encoding: "json"}, } data := map[string]interface{}{ diff --git a/KC-Core/pkg/bssci/observer_test.go b/KC-Core/pkg/bssci/observer_test.go index fa39f94..56966a6 100644 --- a/KC-Core/pkg/bssci/observer_test.go +++ b/KC-Core/pkg/bssci/observer_test.go @@ -1,34 +1,31 @@ package bssci import ( - "context" "testing" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" - "go.uber.org/zap" - "go.uber.org/zap/zaptest/observer" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) -// Quick test to verify observer captures WARN logs +// TestObserverCapture verifies the recording logger captures WARN entries so +// log-assertion tests can rely on it. func TestObserverCapture(t *testing.T) { - observedCore, observedLogs := observer.New(zap.WarnLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + testLogger := newRecordingLogger() - ctx := context.Background() + ctx := testutil.TestContext() - // Log a WARN testLogger.WarnContext(ctx, "Test warning message", "field", "value") - // Check if captured - allLogs := observedLogs.All() - warnLogs := observedLogs.FilterMessage("Test warning message").All() + warnLogs := testLogger.getEntriesByLevel("WARN") - t.Logf("Total logs: %d, Warn logs: %d", len(allLogs), len(warnLogs)) + t.Logf("Total logs: %d, Warn logs: %d", len(testLogger.entries), len(warnLogs)) - if len(allLogs) == 0 { - t.Fatal("Observer didn't capture any logs!") + if len(testLogger.entries) == 0 { + t.Fatal("Recorder didn't capture any logs!") } if len(warnLogs) == 0 { - t.Fatal("Observer didn't capture the warn message!") + t.Fatal("Recorder didn't capture the warn message!") + } + if warnLogs[0].msg != "Test warning message" { + t.Fatalf("unexpected message %q", warnLogs[0].msg) } } diff --git a/KC-Core/pkg/bssci/outbound_validation_test.go b/KC-Core/pkg/bssci/outbound_validation_test.go index 353f21c..6b7f79b 100644 --- a/KC-Core/pkg/bssci/outbound_validation_test.go +++ b/KC-Core/pkg/bssci/outbound_validation_test.go @@ -128,7 +128,6 @@ func TestValidateAllCatalogCommands(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ ProtocolSessionState: ProtocolSessionState{ @@ -157,7 +156,6 @@ func TestValidationMissingCommand(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ ProtocolSessionState: ProtocolSessionState{ @@ -192,7 +190,6 @@ func TestValidationMissingOpId(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ ProtocolSessionState: ProtocolSessionState{ @@ -278,7 +275,6 @@ func TestValidationExtraField(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ ProtocolSessionState: ProtocolSessionState{ @@ -314,7 +310,6 @@ func TestValidationUnknownCommand(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ ProtocolSessionState: ProtocolSessionState{ @@ -350,7 +345,6 @@ func TestValidationMarshalFailure(t *testing.T) { logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll _ = server // Create a struct with unmarshalable field (channel type) @@ -386,7 +380,6 @@ func TestValidationStructMessages(t *testing.T) { logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ ProtocolSessionState: ProtocolSessionState{ ID: "test-session", diff --git a/KC-Core/pkg/bssci/pending_ops_collision_test.go b/KC-Core/pkg/bssci/pending_ops_collision_test.go index 90dd0d2..234c1ce 100644 --- a/KC-Core/pkg/bssci/pending_ops_collision_test.go +++ b/KC-Core/pkg/bssci/pending_ops_collision_test.go @@ -1,7 +1,6 @@ package bssci import ( - "context" "testing" "time" @@ -9,6 +8,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestPendingOpsCompositeKey verifies that the sessionOpKey composite key @@ -63,7 +64,7 @@ func TestPendingOpsCompositeKey(t *testing.T) { } // Use StatusService to store operations - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session1, sharedOpID, op1, session1.DbSessionID) require.NoError(t, err, "Should record session 1 operation") err = server.statusSvc.RecordPendingOperation(ctx, session2, sharedOpID, op2, session2.DbSessionID) @@ -125,7 +126,7 @@ func TestPendingOpsSessionIsolation(t *testing.T) { } // Store 15 operations using StatusService (3 sessions x 5 operations each) - ctx := context.Background() + ctx := testutil.TestContext() for sessionIdx, session := range sessions { for opIdx, opType := range operationTypes { opID := int64(opIdx + 1) @@ -254,7 +255,7 @@ func TestPendingOperationSessionSlug(t *testing.T) { DbSessionID: 1, }, } - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, opID, op, session.DbSessionID) require.NoError(t, err, "Should record pending operation") @@ -286,7 +287,7 @@ func TestPendingOpsRaceCondition(t *testing.T) { {ProtocolSessionState: ProtocolSessionState{ID: "concurrent-2", DbSessionID: 2}}, } - ctx := context.Background() + ctx := testutil.TestContext() // Simulate sequential operations (real concurrent testing with -race flag) for i, session := range sessions { diff --git a/KC-Core/pkg/bssci/phase_c2_regression_test.go b/KC-Core/pkg/bssci/phase_c2_regression_test.go index abccbd5..e1fe443 100644 --- a/KC-Core/pkg/bssci/phase_c2_regression_test.go +++ b/KC-Core/pkg/bssci/phase_c2_regression_test.go @@ -123,7 +123,6 @@ func (r *recordingLogger) getEntriesByLevel(level string) []logEntry { } // testServerWithStubPropagation: REMOVED - no longer needed. -// Tests now inject stub functions via Server.broadcastFn hook directly. // Detach Propagate Regression Tests // These tests validate fixes for critical blockers in detach propagate implementation @@ -137,7 +136,6 @@ func Test_wrapOutboundMessage_TypedStruct(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll tests := []struct { name string @@ -226,7 +224,6 @@ func Test_wrapOutboundMessage_AlreadyMessage(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll original := &Message{ Command: mioty.CmdError, @@ -256,7 +253,6 @@ func Test_wrapOutboundMessage_Map(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll inputMap := map[string]interface{}{ "command": mioty.CmdAttachComplete, @@ -482,7 +478,6 @@ func Test_validateOutboundMessage_CatalogTokens(t *testing.T) { config: &Config{}, logger: logger, } - server.broadcastFn = server.SendAttachPropagateToAll session := &Session{ ProtocolSessionState: ProtocolSessionState{ @@ -516,7 +511,6 @@ func Test_validateOutboundMessage_CatalogTokens(t *testing.T) { config: &Config{}, logger: logger2, } - server2.broadcastFn = server2.SendAttachPropagateToAll validMsg := &Message{ Command: mioty.CmdConnectResponse, diff --git a/KC-Core/pkg/bssci/protocol_edge_cases_test.go b/KC-Core/pkg/bssci/protocol_edge_cases_test.go index b5ed1be..6e5bc79 100644 --- a/KC-Core/pkg/bssci/protocol_edge_cases_test.go +++ b/KC-Core/pkg/bssci/protocol_edge_cases_test.go @@ -1,21 +1,22 @@ package bssci_test import ( - "context" "encoding/json" "fmt" "net" "testing" "time" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vmihailenco/msgpack/v5" - "go.uber.org/zap" - "go.uber.org/zap/zaptest/observer" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestProtocolEdgeCases verifies BSSCI §2.4 and §2.5 edge case handling @@ -813,7 +814,7 @@ func TestConResumeCountersIndependentlyOptional(t *testing.T) { // just field conversion logic without attach handler provisioning dependencies. func TestTypeBytesIntegration(t *testing.T) { testLogger := logger.NewNop() - ctx := context.Background() + ctx := testutil.TestContext() tests := []struct { name string @@ -912,8 +913,8 @@ func TestNormalizeResponseCommandsWithUnknownFields(t *testing.T) { for _, encoding := range []string{"json", "msgpack"} { t.Run(tt.desc+"_"+encoding, func(t *testing.T) { // Use observing logger to verify WARN logs for unknown fields - observedCore, observedLogs := observer.New(zap.WarnLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures WARN+ + testLogger := observedLogs mockConn := &edgeMockConn{encoding: encoding} mockConn.Reset() @@ -955,8 +956,8 @@ func TestNormalizeResponseCommandsWithUnknownFields(t *testing.T) { } // Verify unknown field warnings were logged - allLogs := observedLogs.All() - warnLogs := observedLogs.FilterMessage("Unknown field in message - dropping for forward compatibility").All() + allLogs := observedLogs.AllAtLeast("WARN") + warnLogs := observedLogs.FilterMessage("Unknown field in message - dropping for forward compatibility") require.GreaterOrEqual(t, len(warnLogs), 1, "%s should log WARN for unknown fields (found %d WARN logs total, %d 'unknown field' logs)", tt.desc, len(allLogs), len(warnLogs)) @@ -964,7 +965,7 @@ func TestNormalizeResponseCommandsWithUnknownFields(t *testing.T) { // Verify log contains command name in context foundCommandLog := false for _, log := range warnLogs { - fields := log.ContextMap() + fields := log.FieldMap() if cmd, ok := fields["command"].(string); ok && cmd == tt.command { foundCommandLog = true break diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index 327a1f3..82a6240 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -115,15 +115,15 @@ type Server struct { storage interfaces.Storage // Uses repository interfaces // Injected services - sessionSvc SessionService - versionNegotiator VersionNegotiator - downlinkSvc DownlinkService - statusSvc StatusService - connectionSvc ConnectionService - broadcaster SCACIBroadcaster - queueSerializer QueueSerializer // Downlink response frame builder - auditLogger AuditLogger // Downlink audit event recorder - tenantResolver TenantResolver // Queue-to-tenant mapping (replaces queueTenants map) + sessionSvc SessionService + versionNegotiator VersionNegotiator + downlinkSvc DownlinkService + statusSvc StatusService + connectionRegistry BaseStationConnectionRegistry + broadcaster SCACIBroadcaster + queueSerializer QueueSerializer // Downlink response frame builder + auditLogger AuditLogger // Downlink audit event recorder + tenantResolver TenantResolver // Queue-to-tenant mapping (replaces queueTenants map) // Organization resolution orgResolver org.Resolver // Organization UUID → tenant ID resolution @@ -172,14 +172,6 @@ type Server struct { // Resolves blueprints and decodes payloads for uplink messages blueprintDecoder BlueprintDecoder blueprintResolver BlueprintResolver - - // broadcastFn allows tests to inject stub implementations for SendAttachPropagateToAll. - // Production code initializes this to s.SendAttachPropagateToAll in constructors. - broadcastFn func(endpointEUI uint64, nwkSnKey []byte, shortAddr uint16, bidirectional bool, lastPacketCnt uint32, dualChannel bool, repetition uint8, wideCarrOff bool, longBlkDist bool) []error - - // testNormalizationSpy allows tests to observe normalization decisions in handleMessage. - // Production code leaves this nil. Tests set it to capture (command, shouldNormalize) pairs. - testNormalizationSpy func(cmd string, normalized bool) } // Compile-time interface assertions @@ -740,7 +732,7 @@ func NewServer( versionNegotiator VersionNegotiator, downlinkSvc DownlinkService, statusSvc StatusService, - connectionSvc ConnectionService, + connectionRegistry BaseStationConnectionRegistry, broadcaster SCACIBroadcaster, queueSerializer QueueSerializer, auditLogger AuditLogger, @@ -758,7 +750,7 @@ func NewServer( // Initialize key encryptor for sensitive data keyEncryptor, err := crypto.NewKeyEncryptor() if err != nil { - log.Warn(LogBSSCIFailedToInitializeKeyEncryptor, "error", err) + log.WarnContext(ctx, LogBSSCIFailedToInitializeKeyEncryptor, "error", err) // Continue without encryption rather than failing } @@ -778,23 +770,20 @@ func NewServer( endpointRepo: endpointRepo, keyEncryptor: keyEncryptor, // Injected services - sessionSvc: sessionSvc, - versionNegotiator: versionNegotiator, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionSvc: connectionSvc, - queueSerializer: queueSerializer, - auditLogger: auditLogger, - tenantResolver: tenantResolver, - broadcaster: broadcaster, + sessionSvc: sessionSvc, + versionNegotiator: versionNegotiator, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionRegistry: connectionRegistry, + queueSerializer: queueSerializer, + auditLogger: auditLogger, + tenantResolver: tenantResolver, + broadcaster: broadcaster, // Organization resolution orgResolver: orgResolver, defaultTenantID: defaultTenantID, } - // Initialize broadcast hook for production (tests can override) - s.broadcastFn = s.SendAttachPropagateToAll - // Register command handlers s.registerHandlers() @@ -1582,6 +1571,15 @@ func (s *Server) handleConnection(conn net.Conn) { } } +// shouldNormalizeCommand reports whether an incoming command's payload must be +// normalized: only BS->SC and bidirectional inbound commands are, so the SC +// never validates its own outbound responses, and unknown commands are skipped +// as a safe default for forward compatibility (BSSCI §2.4). +func shouldNormalizeCommand(command string) bool { + direction, exists := CommandDirectionMap[command] + return exists && (direction == DirectionBStoSC || direction == DirectionBidirectional) +} + // handleMessage routes messages to appropriate handlers func (s *Server) handleMessage(session *Session, msg *Message, data map[string]interface{}) error { // BSSCI §2.4: Normalize incoming payload to validate fields and detect unknown fields @@ -1589,17 +1587,8 @@ func (s *Server) handleMessage(session *Session, msg *Message, data map[string]i // Issue #3-4 Fix: Only normalize BS→SC commands (inbound) to avoid validating our own SC→BS responses ctx := s.sessionContext(session) - // Check if this command should be normalized (only BS→SC and bidirectional inbound commands) - direction, exists := CommandDirectionMap[msg.Command] - shouldNormalize := exists && (direction == DirectionBStoSC || direction == DirectionBidirectional) - - // Notify test spy if present (for TestNormalizationOnlyForBStoSCCommands) - if s.testNormalizationSpy != nil { - s.testNormalizationSpy(msg.Command, shouldNormalize) - } - // Only normalize inbound BS→SC and bidirectional commands (skip SC→BS responses and unknown commands for safety) - if shouldNormalize { + if shouldNormalizeCommand(msg.Command) { normalizedData, err := normalizePayload(ctx, s.logger, msg.Command, data) if err != nil { // Normalization failed (mandatory field missing, invalid type, etc.) @@ -1893,20 +1882,27 @@ func (s *Server) startDLRXQueryExpiryWorker() { case <-s.ctx.Done(): return case <-ticker.C: - cutoff := time.Now().Add(-s.dlrxQueryTimeout()) - expired, err := s.storage.DLRXStatus().ExpireDLRXStatusQuery(s.safeCtx(), cutoff) - if err != nil { - s.logger.WarnContext(s.safeCtx(), LogBSSCIDLRXQueryExpirySweepFailed, "error", err) - continue - } - if expired > 0 { - s.logger.InfoContext(s.safeCtx(), LogBSSCIDLRXQueriesExpired, "count", expired) - } + s.sweepExpiredDLRXQueries(time.Now()) } } }() } +// sweepExpiredDLRXQueries expires every dlRxStatQry older than the configured +// timeout relative to now. It is the worker's per-tick body, separated so the +// cutoff arithmetic is testable without the ticker. +func (s *Server) sweepExpiredDLRXQueries(now time.Time) { + cutoff := now.Add(-s.dlrxQueryTimeout()) + expired, err := s.storage.DLRXStatus().ExpireDLRXStatusQuery(s.safeCtx(), cutoff) + if err != nil { + s.logger.WarnContext(s.safeCtx(), LogBSSCIDLRXQueryExpirySweepFailed, "error", err) + return + } + if expired > 0 { + s.logger.InfoContext(s.safeCtx(), LogBSSCIDLRXQueriesExpired, "count", expired) + } +} + // rejectConnect fails the connect operation per BSSCI §5.17: an error frame // replaces the normal response, the session enters AwaitingConnectErrorAck, // and the connection stays open until the base station acknowledges with @@ -2011,7 +2007,7 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m // the error/errorAck sequence before any conRsp is offered ctx := s.sessionContext(session) euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() - baseStation, bsErr := s.connectionSvc.GetBaseStationGlobal(ctx, euiBytes, s.connectionMgr) + baseStation, bsErr := s.connectionRegistry.GetBaseStationGlobal(ctx, euiBytes) if bsErr != nil || baseStation == nil { s.logger.ErrorContext(ctx, LogBSSCIBaseStationNotFoundInDatabase, "eui", session.BaseStationEUI, @@ -2319,7 +2315,7 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message // Step 2: register the live connection and base-station online status. A // failure here compensates the just-persisted session and closes without // publishing anything to the live registries. - if err := s.connectionSvc.RegisterConnection(ctx, session, baseStation, s.connectionMgr); err != nil { + if err := s.connectionRegistry.RegisterConnection(ctx, session, baseStation); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateConnectionStatus, "eui", session.BaseStationEUI, "error", err) diff --git a/KC-Core/pkg/bssci/session_lookup_test.go b/KC-Core/pkg/bssci/session_lookup_test.go index 91e9ae5..4e9210b 100644 --- a/KC-Core/pkg/bssci/session_lookup_test.go +++ b/KC-Core/pkg/bssci/session_lookup_test.go @@ -2,7 +2,6 @@ package bssci import ( - "context" "errors" "testing" "time" @@ -10,6 +9,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // createTestServerWithSessions creates a test server with pre-registered sessions @@ -24,7 +25,7 @@ func createTestServerWithSessions(t *testing.T, sessions []*Session) *Server { server := NewTestServerWithMemoryStatusService(log, nil, nil, 1) // Initialize context to prevent nil pointer panics in logging - server.SetContextForTests(context.Background()) + server.SetContextForTests(testutil.TestContext()) // Register test sessions for _, session := range sessions { diff --git a/KC-Core/pkg/bssci/tenant_context_test.go b/KC-Core/pkg/bssci/tenant_context_test.go index 627ff45..75f6264 100644 --- a/KC-Core/pkg/bssci/tenant_context_test.go +++ b/KC-Core/pkg/bssci/tenant_context_test.go @@ -11,6 +11,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // stubSessionService is a no-op session service for smoke tests @@ -96,7 +98,7 @@ func TestAttachPropagateTenantField(t *testing.T) { env.server.mu.Unlock() // Execute attach propagate (Issue #1 fix ensures endpointTenantID is used) - err := env.server.SendAttachPropagateToSession(context.Background(), env.session, endpoint) + err := env.server.SendAttachPropagateToSession(testutil.TestContext(), env.session, endpoint) require.NoError(t, err, "SendAttachPropagateToSession should succeed with cross-tenant scenario") t.Logf("PASS: Issue #1 smoke test: Attach propagate succeeded with endpoint tenant %d and session tenant %d", @@ -158,7 +160,7 @@ func TestDetachCompleteTelemetryFields(t *testing.T) { }, CreatedAt: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := env.server.statusSvc.RecordPendingOperation(ctx, env.session, opID, pendingOp, env.session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") @@ -253,7 +255,7 @@ func TestDetachCompleteOwnerContext(t *testing.T) { Metadata: metadata, CreatedAt: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := env.server.statusSvc.RecordPendingOperation(ctx, env.session, opID, pendingOp, env.session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") diff --git a/KC-Core/pkg/bssci/test_helpers_public.go b/KC-Core/pkg/bssci/test_helpers_public.go index 853ee5a..ddafff8 100644 --- a/KC-Core/pkg/bssci/test_helpers_public.go +++ b/KC-Core/pkg/bssci/test_helpers_public.go @@ -15,8 +15,6 @@ func NewServerForTesting(log logger.Logger) *Server { sessions: make(map[string]*Session), // mu relies on zero-value initialization (valid for sync.RWMutex) } - // Initialize broadcast hook (tests can override) - s.broadcastFn = s.SendAttachPropagateToAll return s } diff --git a/KC-Core/pkg/bssci/test_helpers_test.go b/KC-Core/pkg/bssci/test_helpers_test.go index d7235c9..eb5ddb7 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -48,7 +48,7 @@ func NewTestServer( sessionSvc SessionService, downlinkSvc DownlinkService, statusSvc StatusService, - connectionSvc ConnectionService, + connectionSvc BaseStationConnectionRegistry, broadcaster SCACIBroadcaster, queueSerializer QueueSerializer, auditLogger AuditLogger, @@ -60,28 +60,27 @@ func NewTestServer( } s := &Server{ - logger: log, - storage: storage, - eventStore: eventStore, - tenantID: tenantID, - sessionSvc: sessionSvc, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionSvc: connectionSvc, - broadcaster: broadcaster, - queueSerializer: queueSerializer, - auditLogger: auditLogger, - tenantResolver: tenantResolver, - versionNegotiator: staticTestVersionNegotiator{}, - handlers: make(map[string]HandlerFunc), // Initialize handlers map for tests - sessions: make(map[string]*Session), // Initialize sessions map for tests + logger: log, + storage: storage, + eventStore: eventStore, + tenantID: tenantID, + sessionSvc: sessionSvc, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionRegistry: connectionSvc, + broadcaster: broadcaster, + queueSerializer: queueSerializer, + auditLogger: auditLogger, + tenantResolver: tenantResolver, + versionNegotiator: staticTestVersionNegotiator{}, + handlers: make(map[string]HandlerFunc), // Initialize handlers map for tests + sessions: make(map[string]*Session), // Initialize sessions map for tests } // Wire endpointRepo if storage is available (prevents nil panics in resolveEndpointTenantID) if storage != nil { s.endpointRepo = storage.EndPoints() } // Initialize broadcast hook (tests can override) - s.broadcastFn = s.SendAttachPropagateToAll return s } @@ -265,7 +264,7 @@ func NewTestServerWithBaseStationRepo( sessionSvc SessionService, downlinkSvc DownlinkService, statusSvc StatusService, - connectionSvc ConnectionService, + connectionSvc BaseStationConnectionRegistry, broadcaster SCACIBroadcaster, queueSerializer QueueSerializer, auditLogger AuditLogger, @@ -277,23 +276,22 @@ func NewTestServerWithBaseStationRepo( } s := &Server{ - logger: log, - storage: storage, - basestationRepo: basestationRepo, - tenantID: tenantID, - sessionSvc: sessionSvc, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionSvc: connectionSvc, - broadcaster: broadcaster, - queueSerializer: queueSerializer, - auditLogger: auditLogger, - tenantResolver: tenantResolver, - versionNegotiator: staticTestVersionNegotiator{}, - sessions: make(map[string]*Session), + logger: log, + storage: storage, + basestationRepo: basestationRepo, + tenantID: tenantID, + sessionSvc: sessionSvc, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionRegistry: connectionSvc, + broadcaster: broadcaster, + queueSerializer: queueSerializer, + auditLogger: auditLogger, + tenantResolver: tenantResolver, + versionNegotiator: staticTestVersionNegotiator{}, + sessions: make(map[string]*Session), } // Initialize broadcast hook (tests can override) - s.broadcastFn = s.SendAttachPropagateToAll return s } @@ -582,7 +580,7 @@ func CreateTestServices(log logger.Logger, _ interfaces.SystemEventStore) ( SessionService, DownlinkService, StatusService, - ConnectionService, + BaseStationConnectionRegistry, SCACIBroadcaster, QueueSerializer, AuditLogger, diff --git a/KC-Core/pkg/bssci/testutil/recording_logger.go b/KC-Core/pkg/bssci/testutil/recording_logger.go new file mode 100644 index 0000000..906a970 --- /dev/null +++ b/KC-Core/pkg/bssci/testutil/recording_logger.go @@ -0,0 +1,136 @@ +package testutil + +import ( + "context" + "sync" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" +) + +// RecordedEntry is one captured log call. +type RecordedEntry struct { + Level string + Message string + Fields []interface{} +} + +// levelRank orders levels for AllAtLeast filtering. +var levelRank = map[string]int{ + "DEBUG": 0, + "INFO": 1, + "WARN": 2, + "ERROR": 3, + "FATAL": 4, +} + +// RecordingLogger is a logger.Logger implementation that captures every call +// so tests can assert on emitted log messages without importing zap. +type RecordingLogger struct { + mu sync.Mutex + entries []RecordedEntry +} + +// NewRecordingLogger creates an empty recorder. +func NewRecordingLogger() *RecordingLogger { + return &RecordingLogger{} +} + +func (r *RecordingLogger) record(level, msg string, fields []interface{}) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries = append(r.entries, RecordedEntry{Level: level, Message: msg, Fields: fields}) +} + +// Debug records a DEBUG entry. +func (r *RecordingLogger) Debug(msg string, fields ...interface{}) { r.record("DEBUG", msg, fields) } + +// Info records an INFO entry. +func (r *RecordingLogger) Info(msg string, fields ...interface{}) { r.record("INFO", msg, fields) } + +// Warn records a WARN entry. +func (r *RecordingLogger) Warn(msg string, fields ...interface{}) { r.record("WARN", msg, fields) } + +// Error records an ERROR entry. +func (r *RecordingLogger) Error(msg string, fields ...interface{}) { r.record("ERROR", msg, fields) } + +// Fatal records a FATAL entry (never exits). +func (r *RecordingLogger) Fatal(msg string, fields ...interface{}) { r.record("FATAL", msg, fields) } + +// DebugContext records a DEBUG entry. +func (r *RecordingLogger) DebugContext(_ context.Context, msg string, fields ...interface{}) { + r.record("DEBUG", msg, fields) +} + +// InfoContext records an INFO entry. +func (r *RecordingLogger) InfoContext(_ context.Context, msg string, fields ...interface{}) { + r.record("INFO", msg, fields) +} + +// WarnContext records a WARN entry. +func (r *RecordingLogger) WarnContext(_ context.Context, msg string, fields ...interface{}) { + r.record("WARN", msg, fields) +} + +// ErrorContext records an ERROR entry. +func (r *RecordingLogger) ErrorContext(_ context.Context, msg string, fields ...interface{}) { + r.record("ERROR", msg, fields) +} + +// FatalContext records a FATAL entry (never exits). +func (r *RecordingLogger) FatalContext(_ context.Context, msg string, fields ...interface{}) { + r.record("FATAL", msg, fields) +} + +// WithField returns the same recorder (field scoping is not recorded). +func (r *RecordingLogger) WithField(_ string, _ interface{}) logger.Logger { return r } + +// WithFields returns the same recorder (field scoping is not recorded). +func (r *RecordingLogger) WithFields(_ map[string]interface{}) logger.Logger { return r } + +// All returns every captured entry. +func (r *RecordingLogger) All() []RecordedEntry { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]RecordedEntry, len(r.entries)) + copy(out, r.entries) + return out +} + +// AllAtLeast returns entries at or above the given level (DEBUG < INFO < +// WARN < ERROR < FATAL), mirroring a level-filtered observer. +func (r *RecordingLogger) AllAtLeast(level string) []RecordedEntry { + minRank := levelRank[level] + out := []RecordedEntry{} + for _, e := range r.All() { + if levelRank[e.Level] >= minRank { + out = append(out, e) + } + } + return out +} + +// FilterMessage returns entries whose message equals msg. +func (r *RecordingLogger) FilterMessage(msg string) []RecordedEntry { + out := []RecordedEntry{} + for _, e := range r.All() { + if e.Message == msg { + out = append(out, e) + } + } + return out +} + +// interface guard +var _ logger.Logger = (*RecordingLogger)(nil) + +// FieldMap converts the entry's variadic key-value fields into a map +// (odd-positioned strings are keys). Non-string keys are skipped. +func (e RecordedEntry) FieldMap() map[string]interface{} { + out := make(map[string]interface{}) + for i := 0; i+1 < len(e.Fields); i += 2 { + if key, ok := e.Fields[i].(string); ok { + out[key] = e.Fields[i+1] + } + } + return out +} diff --git a/KC-Core/pkg/bssci/uldata_multibs_test.go b/KC-Core/pkg/bssci/uldata_multibs_test.go index 3692b91..8b54bcd 100644 --- a/KC-Core/pkg/bssci/uldata_multibs_test.go +++ b/KC-Core/pkg/bssci/uldata_multibs_test.go @@ -11,6 +11,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -126,7 +128,7 @@ func TestDLRXHydrationBatchQueryCallCount(t *testing.T) { binary.BigEndian.PutUint64(epEui, bssci.TestEpEui01) // Call mock - _, err := mock.GetLatestDLRXStatusByBaseStations(context.Background(), 1, epEui, bsEuis) + _, err := mock.GetLatestDLRXStatusByBaseStations(testutil.TestContext(), 1, epEui, bsEuis) require.NoError(t, err) // Assert batch method called exactly once (not N times) diff --git a/KC-Core/pkg/bssci/version_interop_test.go b/KC-Core/pkg/bssci/version_interop_test.go index 483e3cf..2dd43e9 100644 --- a/KC-Core/pkg/bssci/version_interop_test.go +++ b/KC-Core/pkg/bssci/version_interop_test.go @@ -17,6 +17,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // interopSCEui exercises exact outbound encoding: the high bit is set and the @@ -27,15 +29,11 @@ const interopSCEui = uint64(0xFFFFFFFFFFFFFFFF) // base station registered under tenant 1 (any EUI accepted). type interopConnectionService struct{} -func (interopConnectionService) GetBaseStation(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { - return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Interop BS"}, nil -} - -func (interopConnectionService) GetBaseStationGlobal(_ context.Context, eui [8]byte, _ *basestation.ConnectionManager) (*basestation.BaseStation, error) { +func (interopConnectionService) GetBaseStationGlobal(_ context.Context, eui [8]byte) (*basestation.BaseStation, error) { return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Interop BS"}, nil } -func (interopConnectionService) RegisterConnection(_ context.Context, _ *Session, _ *basestation.BaseStation, _ *basestation.ConnectionManager) error { +func (interopConnectionService) RegisterConnection(_ context.Context, _ *Session, _ *basestation.BaseStation) error { return nil } @@ -78,7 +76,7 @@ func startInteropServerWithConfig(t *testing.T, encoding string, mutate func(*Co mutate(server.config) } server.RegisterHandlers() - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(testutil.TestContext()) server.ctx = ctx server.cancel = cancel t.Cleanup(cancel) diff --git a/KC-Core/pkg/bssci/vm_handlers_test.go b/KC-Core/pkg/bssci/vm_handlers_test.go index c938b6f..7d22bdf 100644 --- a/KC-Core/pkg/bssci/vm_handlers_test.go +++ b/KC-Core/pkg/bssci/vm_handlers_test.go @@ -1,16 +1,17 @@ package bssci import ( - "context" "testing" "time" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestVMHandlerSignatures validates all VM handler function signatures @@ -47,7 +48,7 @@ func TestVMHandlerSignatures(t *testing.T) { MACType: 1, Timestamp: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, -1, pendingOp, session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") @@ -77,7 +78,7 @@ func TestVMHandlerSignatures(t *testing.T) { MACType: 1, Timestamp: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, -2, pendingOp, session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") @@ -107,7 +108,7 @@ func TestVMHandlerSignatures(t *testing.T) { Endpoint: []byte{0, 0, 0, 0, 0, 0, 0, 1}, Timestamp: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, -3, pendingOp, session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") @@ -138,7 +139,7 @@ func TestVMHandlerSignatures(t *testing.T) { // Only unsolicited BS-initiated operations (like handleVMDLData) return unsupported errors func TestVMCommunityEditionStubs(t *testing.T) { server := createTestServerWithSession() - conn := &testutil.TestConn{Encoding: "msgpack"} + conn := &bsscitest.TestConn{Encoding: "msgpack"} session := &Session{ ProtocolSessionState: ProtocolSessionState{ BaseStationEUI: TestEpEui01, @@ -295,7 +296,7 @@ func TestVMActiveTypesTracking(t *testing.T) { MACType: 1, Timestamp: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, -1, pendingOp, session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") @@ -323,7 +324,7 @@ func TestVMActiveTypesTracking(t *testing.T) { MACType: 1, Timestamp: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, -2, pendingOp, session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") @@ -349,7 +350,7 @@ func TestVMActiveTypesTracking(t *testing.T) { Endpoint: []byte{0, 0, 0, 0, 0, 0, 0, 1}, Timestamp: time.Now(), } - ctx := context.Background() + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, -3, pendingOp, session.DbSessionID) require.NoError(t, err, "Failed to record pending operation") diff --git a/KC-Core/pkg/grpc/export_constants.go b/KC-Core/pkg/grpc/export_constants.go index 4b2e86b..94eb6e3 100644 --- a/KC-Core/pkg/grpc/export_constants.go +++ b/KC-Core/pkg/grpc/export_constants.go @@ -113,19 +113,24 @@ const ( // Log message constants for Certificate service. const ( - LogDownloadCertFailed = "download certificate failed" - LogCertTempDirCreateFailed = "Failed to create temp directory" - LogCertInvalidEUI = "Invalid EUI format" - LogCertInvalidValidityDays = "Invalid validity days" - LogCertDirectoryInfo = "Certificate directory" - LogCertGeneratorNotFound = "Certificate generator not found" - LogCertGeneratorPathInfo = "Certificate generator path" - LogCertGenerationRequested = "certificate generation requested" - LogCertGenerationFailed = "Certificate generation failed" - LogCertParseFailed = "Failed to parse certificate" - LogCertExpiryUpdateFailed = "Failed to update certificate expiry" - LogCertCleanupFailed = "Failed to cleanup temp directory" - LogCertConfigMissingPath = "Certificate generator path not configured and default not found" + LogDownloadCertFailed = "download certificate failed" + LogCertTempDirCreateFailed = "Failed to create temp directory" + LogCertInvalidEUI = "Invalid EUI format" + LogCertInvalidValidityDays = "Invalid validity days" + LogCertDirectoryInfo = "Certificate directory" + LogCertGeneratorNotFound = "Certificate generator not found" + LogCertGeneratorPathInfo = "Certificate generator path" + LogCertGenerationRequested = "certificate generation requested" + LogCertGenerationFailed = "Certificate generation failed" + LogCertParseFailed = "Failed to parse certificate" + LogCertExpiryUpdateFailed = "Failed to update certificate expiry" + LogCertCleanupFailed = "Failed to cleanup temp directory" + LogCertConfigMissingPath = "Certificate generator path not configured and default not found" + LogCertDirectoryNotFound = "Certificate directory not found" + LogCertInvalidServerValidityDays = "Invalid server_validity_days, using default" + LogCertIssuanceRequiresTenant = "certificate issuance requires tenant context" + LogGenerateCertificateFailed = "generate certificate failed" + LogGetServerCertStatusFailed = "get server certificate status failed" // Directory operations LogCertDirCreateFailed = "Failed to create certificate directory" diff --git a/KC-Core/pkg/grpc/interceptors/authorization_test.go b/KC-Core/pkg/grpc/interceptors/authorization_test.go index 747d3e4..44f000c 100644 --- a/KC-Core/pkg/grpc/interceptors/authorization_test.go +++ b/KC-Core/pkg/grpc/interceptors/authorization_test.go @@ -13,6 +13,8 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockRoleResolver records calls and returns preconfigured results. @@ -48,7 +50,7 @@ const testMethod = "/kilocenter.api.v1.CoreService/TestMethod" // newTestContext returns a context populated with a random org UUID and user ID. func newTestContext() context.Context { - ctx := context.Background() + ctx := testutil.TestContext() ctx = pkgcontext.WithOrganizationID(ctx, uuid.New()) ctx = pkgcontext.WithUserID(ctx, uuid.New().String()) return ctx @@ -136,7 +138,7 @@ func TestMissingUserInContext(t *testing.T) { interceptor := NewAuthorizationInterceptor(resolver, policies, noopLogger{}, 30*time.Second) // Context with org but no user - ctx := context.Background() + ctx := testutil.TestContext() ctx = pkgcontext.WithOrganizationID(ctx, uuid.New()) _, err := invokeInterceptor(t, interceptor, ctx) @@ -158,7 +160,7 @@ func TestMissingOrgInContext(t *testing.T) { interceptor := NewAuthorizationInterceptor(resolver, policies, noopLogger{}, 30*time.Second) // Context with user but no org - ctx := context.Background() + ctx := testutil.TestContext() ctx = pkgcontext.WithUserID(ctx, uuid.New().String()) _, err := invokeInterceptor(t, interceptor, ctx) @@ -182,7 +184,7 @@ func TestUnknownRPCPassthrough(t *testing.T) { // Call a method not in the policy map unary := interceptor.UnaryInterceptor() info := &grpc.UnaryServerInfo{FullMethod: "/kilocenter.api.v1.CoreService/UnknownMethod"} - resp, err := unary(context.Background(), nil, info, passHandler) + resp, err := unary(testutil.TestContext(), nil, info, passHandler) if err != nil { t.Fatalf("expected passthrough for unknown RPC, got: %v", err) } diff --git a/KC-Core/pkg/grpc/interceptors/internal_trust_test.go b/KC-Core/pkg/grpc/interceptors/internal_trust_test.go index 805e763..72f6b7f 100644 --- a/KC-Core/pkg/grpc/interceptors/internal_trust_test.go +++ b/KC-Core/pkg/grpc/interceptors/internal_trust_test.go @@ -10,6 +10,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) func TestInternalTrust_OrgRequired_NonExemptMethod(t *testing.T) { @@ -19,7 +21,7 @@ func TestInternalTrust_OrgRequired_NonExemptMethod(t *testing.T) { grpcconst.MetadataKeyInternalTenantID: "42", // no x-kc-internal-org-id }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handler := func(_ context.Context, _ interface{}) (interface{}, error) { t.Error("handler should not be called when org header is missing for non-exempt method") @@ -47,7 +49,7 @@ func TestInternalTrust_OrgOptional_ExemptMethod(t *testing.T) { grpcconst.MetadataKeyInternalTenantID: "42", // no x-kc-internal-org-id }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handlerCalled := false handler := func(_ context.Context, _ interface{}) (interface{}, error) { @@ -71,7 +73,7 @@ func TestInternalTrust_OrgPresent_NonExemptMethod(t *testing.T) { grpcconst.MetadataKeyInternalTenantID: "42", grpcconst.MetadataKeyInternalOrgID: "00000000-0000-0000-0000-000000000001", }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handlerCalled := false handler := func(_ context.Context, _ interface{}) (interface{}, error) { @@ -95,7 +97,7 @@ func TestInternalTrust_CommunityMode_OrgOptional_NonExemptMethod(t *testing.T) { grpcconst.MetadataKeyInternalTenantID: "42", // no x-kc-internal-org-id — community mode makes it optional }) - ctx := metadata.NewIncomingContext(context.Background(), md) + ctx := metadata.NewIncomingContext(testutil.TestContext(), md) handlerCalled := false handler := func(_ context.Context, _ interface{}) (interface{}, error) { diff --git a/KC-Core/pkg/org/community_test.go b/KC-Core/pkg/org/community_test.go index ce9604a..3baa685 100644 --- a/KC-Core/pkg/org/community_test.go +++ b/KC-Core/pkg/org/community_test.go @@ -1,7 +1,6 @@ package org import ( - "context" "crypto/x509" "crypto/x509/pkix" "errors" @@ -10,6 +9,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage" "github.com/google/uuid" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) var ( @@ -23,7 +24,7 @@ func newTestCommunityResolver() *CommunityResolver { func TestCommunityResolver_LookupTenant_DefaultOrg(t *testing.T) { r := newTestCommunityResolver() - tenantID, err := r.LookupTenant(context.Background(), testDefaultOrgUUID) + tenantID, err := r.LookupTenant(testutil.TestContext(), testDefaultOrgUUID) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -35,7 +36,7 @@ func TestCommunityResolver_LookupTenant_DefaultOrg(t *testing.T) { func TestCommunityResolver_LookupTenant_NonDefaultOrg(t *testing.T) { r := newTestCommunityResolver() otherOrg := uuid.New() - _, err := r.LookupTenant(context.Background(), otherOrg) + _, err := r.LookupTenant(testutil.TestContext(), otherOrg) if err == nil { t.Fatal("expected error for non-default org") } @@ -47,7 +48,7 @@ func TestCommunityResolver_LookupTenant_NonDefaultOrg(t *testing.T) { func TestCommunityResolver_ResolveCert(t *testing.T) { r := newTestCommunityResolver() cert := &x509.Certificate{Subject: pkix.Name{CommonName: "test-cert"}} - orgUUID, tenantID, err := r.ResolveCert(context.Background(), cert) + orgUUID, tenantID, err := r.ResolveCert(testutil.TestContext(), cert) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -61,7 +62,7 @@ func TestCommunityResolver_ResolveCert(t *testing.T) { func TestCommunityResolver_ResolveCert_NilCert(t *testing.T) { r := newTestCommunityResolver() - _, _, err := r.ResolveCert(context.Background(), nil) + _, _, err := r.ResolveCert(testutil.TestContext(), nil) if err == nil { t.Fatal("expected error for nil cert") } @@ -69,7 +70,7 @@ func TestCommunityResolver_ResolveCert_NilCert(t *testing.T) { func TestCommunityResolver_GetDefaultOrgForTenant_DefaultTenant(t *testing.T) { r := newTestCommunityResolver() - orgUUID, err := r.GetDefaultOrgForTenant(context.Background(), testDefaultTenantID) + orgUUID, err := r.GetDefaultOrgForTenant(testutil.TestContext(), testDefaultTenantID) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -80,7 +81,7 @@ func TestCommunityResolver_GetDefaultOrgForTenant_DefaultTenant(t *testing.T) { func TestCommunityResolver_GetDefaultOrgForTenant_OtherTenant(t *testing.T) { r := newTestCommunityResolver() - _, err := r.GetDefaultOrgForTenant(context.Background(), 999) + _, err := r.GetDefaultOrgForTenant(testutil.TestContext(), 999) if err == nil { t.Fatal("expected error for non-default tenant") } @@ -91,7 +92,7 @@ func TestCommunityResolver_GetDefaultOrgForTenant_OtherTenant(t *testing.T) { func TestCommunityResolver_ResolveOrgByExternalID(t *testing.T) { r := newTestCommunityResolver() - orgUUID, err := r.ResolveOrgByExternalID(context.Background(), "some-external-id") + orgUUID, err := r.ResolveOrgByExternalID(testutil.TestContext(), "some-external-id") if !errors.Is(err, storage.ErrNotFound) { t.Fatalf("expected ErrNotFound, got: %v", err) } @@ -102,7 +103,7 @@ func TestCommunityResolver_ResolveOrgByExternalID(t *testing.T) { func TestCommunityResolver_ConcurrentAccess(_ *testing.T) { r := newTestCommunityResolver() - ctx := context.Background() + ctx := testutil.TestContext() var wg sync.WaitGroup for i := 0; i < 100; i++ { diff --git a/KC-Core/pkg/roaming/detector_test.go b/KC-Core/pkg/roaming/detector_test.go index ea56200..2ee5260 100644 --- a/KC-Core/pkg/roaming/detector_test.go +++ b/KC-Core/pkg/roaming/detector_test.go @@ -10,6 +10,8 @@ import ( "github.com/stretchr/testify/require" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // Mock for EndpointOwnershipResolver @@ -111,7 +113,7 @@ func TestDetector_DetectRoaming(t *testing.T) { detector := NewDetector(config, mockResolver, mockRecorder) isRoaming, ownerTenantID, err := detector.DetectRoaming( - context.Background(), + testutil.TestContext(), tt.epEui, tt.servingTenantID, ) @@ -178,7 +180,7 @@ func TestDetector_ValidateRoamingAllowed(t *testing.T) { config := &DetectorConfig{} detector := NewDetector(config, mockResolver, mockRecorder) - err := detector.ValidateRoamingAllowed(context.Background(), tt.ownerTenantID, tt.servingTenantID) + err := detector.ValidateRoamingAllowed(testutil.TestContext(), tt.ownerTenantID, tt.servingTenantID) if tt.wantErr { assert.Error(t, err) @@ -239,9 +241,9 @@ func TestDetector_RecordEvents(t *testing.T) { var err error if tt.isAttach { - err = detector.RecordAttachEvent(context.Background(), tt.epEui, 1, 2, []byte{0xAA, 0xBB}) + err = detector.RecordAttachEvent(testutil.TestContext(), tt.epEui, 1, 2, []byte{0xAA, 0xBB}) } else { - err = detector.RecordDetachEvent(context.Background(), tt.epEui, 1, 2, []byte{0xCC, 0xDD}) + err = detector.RecordDetachEvent(testutil.TestContext(), tt.epEui, 1, 2, []byte{0xCC, 0xDD}) } if tt.wantErr { @@ -270,7 +272,7 @@ func TestDetector_CacheIntegration(t *testing.T) { } detector := NewDetector(config, mockResolver, mockRecorder) - ctx := context.Background() + ctx := testutil.TestContext() // First call - should hit database isRoaming1, owner1, err1 := detector.DetectRoaming(ctx, epEui, 2) @@ -316,7 +318,7 @@ func TestDetector_Metrics(t *testing.T) { detector := NewDetector(config, mockResolver, mockRecorder) // Perform detection - _, _, err := detector.DetectRoaming(context.Background(), epEui, 2) + _, _, err := detector.DetectRoaming(testutil.TestContext(), epEui, 2) require.NoError(t, err) // Get metrics diff --git a/KC-Core/pkg/scaci/broadcast_dldata_result_test.go b/KC-Core/pkg/scaci/broadcast_dldata_result_test.go index 512b1c2..fe112d3 100644 --- a/KC-Core/pkg/scaci/broadcast_dldata_result_test.go +++ b/KC-Core/pkg/scaci/broadcast_dldata_result_test.go @@ -15,15 +15,13 @@ import ( "testing" "time" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + pkgmioty "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" ) // ============================================================================= @@ -442,8 +440,8 @@ func TestBroadcastDLDataResult_ErrorLogging_UsesSessionContext(t *testing.T) { // Verify that error logging uses s.sessionContext per updated implementation // Setup observed logger - observedCore, observedLogs := observer.New(zapcore.ErrorLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures ERROR+ + testLogger := observedLogs // Simulate the error logging pattern from server.go:1349-1351 session := &Session{ @@ -461,7 +459,7 @@ func TestBroadcastDLDataResult_ErrorLogging_UsesSessionContext(t *testing.T) { "error", sendErr) // Verify log entry - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("ERROR") assert.Len(t, logs, 1, "should have 1 error log") if len(logs) > 0 { @@ -469,10 +467,7 @@ func TestBroadcastDLDataResult_ErrorLogging_UsesSessionContext(t *testing.T) { assert.Equal(t, LogSCACISendDLResultToACFailed, entry.Message) // Verify context fields are present - fieldMap := make(map[string]interface{}) - for _, field := range entry.Context { - fieldMap[field.Key] = field.Interface - } + fieldMap := entry.FieldMap() assert.Contains(t, fieldMap, "acEui", "should include acEui in context") assert.Contains(t, fieldMap, "error", "should include error in context") } diff --git a/KC-Core/pkg/scaci/error_recorder_test.go b/KC-Core/pkg/scaci/error_recorder_test.go index 9248c9a..932e86b 100644 --- a/KC-Core/pkg/scaci/error_recorder_test.go +++ b/KC-Core/pkg/scaci/error_recorder_test.go @@ -11,6 +11,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -139,7 +141,7 @@ func TestRecordOutboundError_UsesDefaultCodeWhenCatalogCodeIsZero(t *testing.T) ).Return(nil) posixCode, _, err := recorder.RecordOutboundError( - context.Background(), + testutil.TestContext(), session, 1, // opId CmdError, // command @@ -198,7 +200,7 @@ func TestRecordOutboundError_UsesCatalogCodeWhenNonZero(t *testing.T) { ).Return(nil) posixCode, _, err := recorder.RecordOutboundError( - context.Background(), + testutil.TestContext(), session, 1, CmdError, @@ -242,7 +244,7 @@ func TestCompleteErrorHandshake_CallsCompleteFailedOperation(t *testing.T) { }), ).Return(nil) - err := recorder.CompleteErrorHandshake(context.Background(), session, 1) + err := recorder.CompleteErrorHandshake(testutil.TestContext(), session, 1) assert.NoError(t, err) mockRepo.AssertExpectations(t) @@ -255,7 +257,7 @@ func TestCompleteErrorHandshake_NilRepoReturnsNil(t *testing.T) { session := &Session{ID: 100, TenantID: 42} - err := recorder.CompleteErrorHandshake(context.Background(), session, 1) + err := recorder.CompleteErrorHandshake(testutil.TestContext(), session, 1) assert.NoError(t, err) } @@ -267,7 +269,7 @@ func TestCompleteErrorHandshake_ZeroSessionIDReturnsNil(t *testing.T) { session := &Session{ID: 0, TenantID: 42} - err := recorder.CompleteErrorHandshake(context.Background(), session, 1) + err := recorder.CompleteErrorHandshake(testutil.TestContext(), session, 1) assert.NoError(t, err) // CompleteFailedOperation should NOT be called mockRepo.AssertNotCalled(t, "CompleteFailedOperation") @@ -290,7 +292,7 @@ func TestCompleteErrorHandshake_PropagatesRepoError(t *testing.T) { mock.Anything, ).Return(expectedErr) - err := recorder.CompleteErrorHandshake(context.Background(), session, 1) + err := recorder.CompleteErrorHandshake(testutil.TestContext(), session, 1) assert.Error(t, err) assert.Equal(t, expectedErr, err) @@ -338,7 +340,7 @@ func TestRecordInboundError_PersistsACError(t *testing.T) { message, ).Return(nil) - err := recorder.RecordInboundError(context.Background(), session, 1, posixCode, message) + err := recorder.RecordInboundError(testutil.TestContext(), session, 1, posixCode, message) assert.NoError(t, err) mockRepo.AssertExpectations(t) diff --git a/KC-Core/pkg/scaci/handler_connect_recording_test.go b/KC-Core/pkg/scaci/handler_connect_recording_test.go index 6ee2b0e..c0796d9 100644 --- a/KC-Core/pkg/scaci/handler_connect_recording_test.go +++ b/KC-Core/pkg/scaci/handler_connect_recording_test.go @@ -18,6 +18,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -117,7 +119,7 @@ func TestOperationRecorderMockRecord(t *testing.T) { "snAcUuid": "00112233445566778899AABBCCDDEEFF", "resumed": false, } - err := mockRecorder.Record(context.Background(), session, 0, CmdConnect, models.OperationDirectionInbound, data) + err := mockRecorder.Record(testutil.TestContext(), session, 0, CmdConnect, models.OperationDirectionInbound, data) assert.NoError(t, err) mockRecorder.AssertExpectations(t) @@ -285,7 +287,7 @@ func TestPingRecordingConfigFlag(t *testing.T) { "opId": int64(5), "initiator": "ac", } - err := mockRecorder.Record(context.Background(), session, 5, CmdPing, models.OperationDirectionInbound, data) + err := mockRecorder.Record(testutil.TestContext(), session, 5, CmdPing, models.OperationDirectionInbound, data) assert.NoError(t, err) } @@ -341,7 +343,7 @@ func TestConnectOperationDirections(t *testing.T) { ).Return(nil) data := map[string]interface{}{"test": true} - err := mockRecorder.Record(context.Background(), session, 0, tc.command, tc.direction, data) + err := mockRecorder.Record(testutil.TestContext(), session, 0, tc.command, tc.direction, data) assert.NoError(t, err) mockRecorder.AssertExpectations(t) @@ -376,7 +378,7 @@ func TestPingOperationDirections(t *testing.T) { ).Return(nil) data := map[string]interface{}{"opId": int64(5), "initiator": "ac"} - err := mockRecorder.Record(context.Background(), session, 5, tc.command, tc.direction, data) + err := mockRecorder.Record(testutil.TestContext(), session, 5, tc.command, tc.direction, data) assert.NoError(t, err) mockRecorder.AssertExpectations(t) @@ -410,7 +412,7 @@ func TestSCInitiatedPingOperationDirections(t *testing.T) { ).Return(nil) data := map[string]interface{}{"opId": int64(-10), "initiator": "sc"} - err := mockRecorder.Record(context.Background(), session, -10, tc.command, tc.direction, data) + err := mockRecorder.Record(testutil.TestContext(), session, -10, tc.command, tc.direction, data) assert.NoError(t, err) mockRecorder.AssertExpectations(t) @@ -438,7 +440,7 @@ func TestRecordingErrorNonBlocking(t *testing.T) { ).Return(assert.AnError) // Return an error data := map[string]interface{}{"test": true} - err := mockRecorder.Record(context.Background(), session, 0, CmdConnect, models.OperationDirectionInbound, data) + err := mockRecorder.Record(testutil.TestContext(), session, 0, CmdConnect, models.OperationDirectionInbound, data) // Error is returned, but in handler code this is just logged (non-blocking) assert.Error(t, err) @@ -507,17 +509,17 @@ func TestConnectStateTransitions(t *testing.T) { // Simulate the handler flow // 1. Record Connect request requestData := map[string]interface{}{"version": "1.0.0", "acEui": "0123456789ABCDEF"} - err := mockRecorder.Record(context.Background(), session, 0, CmdConnect, models.OperationDirectionInbound, requestData) + err := mockRecorder.Record(testutil.TestContext(), session, 0, CmdConnect, models.OperationDirectionInbound, requestData) assert.NoError(t, err) // 2. Update state to acknowledged (response phase) responseData := map[string]interface{}{"version": "1.0.0", "scEui": "FEDCBA9876543210"} - err = mockOpRepo.UpdateOperationState(context.Background(), session.ID, 0, models.OperationStateAcknowledged, responseData) + err = mockOpRepo.UpdateOperationState(testutil.TestContext(), session.ID, 0, models.OperationStateAcknowledged, responseData) assert.NoError(t, err) // 3. Update state to completed (complete phase) completeData := map[string]interface{}{"tlsVersion": "TLS 1.3", "cipherSuite": "TLS_AES_256_GCM_SHA384"} - err = mockOpRepo.UpdateOperationState(context.Background(), session.ID, 0, models.OperationStateCompleted, completeData) + err = mockOpRepo.UpdateOperationState(testutil.TestContext(), session.ID, 0, models.OperationStateCompleted, completeData) assert.NoError(t, err) // Verify expectations: exactly one Record, two UpdateOperationState @@ -545,7 +547,7 @@ func TestConnectResponseUsesUpdateNotRecord(t *testing.T) { ).Return(nil) responseData := map[string]interface{}{"version": "1.0.0"} - err := mockOpRepo.UpdateOperationState(context.Background(), 1, 0, models.OperationStateAcknowledged, responseData) + err := mockOpRepo.UpdateOperationState(testutil.TestContext(), 1, 0, models.OperationStateAcknowledged, responseData) assert.NoError(t, err) // Record should NOT have been called @@ -570,7 +572,7 @@ func TestConnectCompleteUsesUpdateNotRecord(t *testing.T) { ).Return(nil) completeData := map[string]interface{}{"tlsVersion": "TLS 1.3"} - err := mockOpRepo.UpdateOperationState(context.Background(), 1, 0, models.OperationStateCompleted, completeData) + err := mockOpRepo.UpdateOperationState(testutil.TestContext(), 1, 0, models.OperationStateCompleted, completeData) assert.NoError(t, err) // Record should NOT have been called @@ -615,15 +617,15 @@ func TestPingStateTransitions_ACInitiated(t *testing.T) { // Simulate the handler flow requestData := map[string]interface{}{"opId": opId, "initiator": "ac"} - err := mockRecorder.Record(context.Background(), session, opId, CmdPing, models.OperationDirectionInbound, requestData) + err := mockRecorder.Record(testutil.TestContext(), session, opId, CmdPing, models.OperationDirectionInbound, requestData) assert.NoError(t, err) responseData := map[string]interface{}{"opId": opId, "initiator": "ac"} - err = mockOpRepo.UpdateOperationState(context.Background(), session.ID, opId, models.OperationStateAcknowledged, responseData) + err = mockOpRepo.UpdateOperationState(testutil.TestContext(), session.ID, opId, models.OperationStateAcknowledged, responseData) assert.NoError(t, err) completeData := map[string]interface{}{"opId": opId, "initiator": "ac"} - err = mockOpRepo.UpdateOperationState(context.Background(), session.ID, opId, models.OperationStateCompleted, completeData) + err = mockOpRepo.UpdateOperationState(testutil.TestContext(), session.ID, opId, models.OperationStateCompleted, completeData) assert.NoError(t, err) mockRecorder.AssertExpectations(t) @@ -668,15 +670,15 @@ func TestPingStateTransitions_SCInitiated(t *testing.T) { // Simulate the handler flow requestData := map[string]interface{}{"opId": opId, "initiator": "sc"} - err := mockRecorder.Record(context.Background(), session, opId, CmdPing, models.OperationDirectionOutbound, requestData) + err := mockRecorder.Record(testutil.TestContext(), session, opId, CmdPing, models.OperationDirectionOutbound, requestData) assert.NoError(t, err) responseData := map[string]interface{}{"opId": opId, "initiator": "sc"} - err = mockOpRepo.UpdateOperationState(context.Background(), session.ID, opId, models.OperationStateAcknowledged, responseData) + err = mockOpRepo.UpdateOperationState(testutil.TestContext(), session.ID, opId, models.OperationStateAcknowledged, responseData) assert.NoError(t, err) completeData := map[string]interface{}{"opId": opId, "initiator": "sc"} - err = mockOpRepo.UpdateOperationState(context.Background(), session.ID, opId, models.OperationStateCompleted, completeData) + err = mockOpRepo.UpdateOperationState(testutil.TestContext(), session.ID, opId, models.OperationStateCompleted, completeData) assert.NoError(t, err) mockRecorder.AssertExpectations(t) @@ -698,7 +700,7 @@ func TestStateTransitionErrorNonBlocking(t *testing.T) { ).Return(assert.AnError) responseData := map[string]interface{}{"test": true} - err := mockOpRepo.UpdateOperationState(context.Background(), 1, 0, models.OperationStateAcknowledged, responseData) + err := mockOpRepo.UpdateOperationState(testutil.TestContext(), 1, 0, models.OperationStateAcknowledged, responseData) // Error is returned, but in handler code this is just logged (non-blocking) assert.Error(t, err) diff --git a/KC-Core/pkg/scaci/handler_operations_test.go b/KC-Core/pkg/scaci/handler_operations_test.go index 779fd9a..33699e1 100644 --- a/KC-Core/pkg/scaci/handler_operations_test.go +++ b/KC-Core/pkg/scaci/handler_operations_test.go @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/vmihailenco/msgpack/v5" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // createTestServer creates a Server with minimal dependencies for handleULDataTransmit tests. @@ -1013,7 +1015,7 @@ func TestRevokeEndpointDownlinks_NilDLSvc_Graceful(t *testing.T) { dlSvc: nil, // nil to test defensive guard } - ctx := context.Background() + ctx := testutil.TestContext() var eui [8]byte binary.BigEndian.PutUint64(eui[:], 0x1234567890ABCDEF) @@ -2062,7 +2064,7 @@ func TestProcessDLDataQueueCore_NoDirectStatusWriteOnSuccess(t *testing.T) { UserData: [][]byte{{0x01, 0x02, 0x03}}, } - result, errToken, posixCode := server.processDLDataQueueCore(context.Background(), session, opID, req) + result, errToken, posixCode := server.processDLDataQueueCore(testutil.TestContext(), session, opID, req) require.NotNil(t, result) assert.Equal(t, uint64(queID), result.QueID) assert.Equal(t, "", errToken) @@ -2118,7 +2120,7 @@ func TestProcessDLDataQueueCore_NoDirectStatusWriteOnSchedulerFailure(t *testing UserData: [][]byte{{0xAA}}, } - result, errToken, posixCode := server.processDLDataQueueCore(context.Background(), session, opID, req) + result, errToken, posixCode := server.processDLDataQueueCore(testutil.TestContext(), session, opID, req) assert.Nil(t, result) assert.Equal(t, errSchedulerUnavailable, errToken) assert.Equal(t, POSIX_ENOTSUP, posixCode) diff --git a/KC-Core/pkg/scaci/handler_status_test.go b/KC-Core/pkg/scaci/handler_status_test.go index 263c387..a7ee4b1 100644 --- a/KC-Core/pkg/scaci/handler_status_test.go +++ b/KC-Core/pkg/scaci/handler_status_test.go @@ -9,12 +9,13 @@ package scaci import ( - "context" "testing" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // ============================================================================ @@ -48,7 +49,7 @@ func TestHandleStatus_RecordsOperation(t *testing.T) { // Simulate the recording logic err := mockRecorder.Record( - context.Background(), + testutil.TestContext(), session, opId, CmdStatus, @@ -88,7 +89,7 @@ func TestHandleStatus_UpdatesStateToAcknowledged(t *testing.T) { // Simulate the state update logic err := mockOpRepo.UpdateOperationState( - context.Background(), + testutil.TestContext(), session.ID, opId, models.OperationStateAcknowledged, @@ -130,7 +131,7 @@ func TestHandleStatusComplete_MarksCompleted(t *testing.T) { // Simulate the state update logic err := mockOpRepo.UpdateOperationState( - context.Background(), + testutil.TestContext(), session.ID, opId, models.OperationStateCompleted, @@ -168,7 +169,7 @@ func TestHandleStatus_RecordingFailure_ContinuesHandling(t *testing.T) { ).Return(assert.AnError) err := mockRecorder.Record( - context.Background(), + testutil.TestContext(), session, opId, CmdStatus, diff --git a/KC-Core/pkg/scaci/replay_epstatus_test.go b/KC-Core/pkg/scaci/replay_epstatus_test.go index 4545a73..4c8a72a 100644 --- a/KC-Core/pkg/scaci/replay_epstatus_test.go +++ b/KC-Core/pkg/scaci/replay_epstatus_test.go @@ -15,16 +15,14 @@ import ( "testing" "time" - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vmihailenco/msgpack/v5" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" ) // ============================================================================= @@ -395,8 +393,8 @@ func TestReplayEPStatus_MissingEpEui_ReturnsErrorAndLogs(t *testing.T) { } // Capture log output with observer at Error level - observedCore, observedLogs := observer.New(zapcore.ErrorLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures ERROR+ + testLogger := observedLogs s := &Server{ logger: testLogger, @@ -415,20 +413,15 @@ func TestReplayEPStatus_MissingEpEui_ReturnsErrorAndLogs(t *testing.T) { assert.Contains(t, err.Error(), "missing/invalid epEui") // Assert: LogSCACIReplayEPStatusInvalidData was logged - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("ERROR") found := false for _, entry := range logs { if entry.Message == LogSCACIReplayEPStatusInvalidData { found = true // Verify context fields - for _, field := range entry.Context { - if field.Key == "opId" { - assert.Equal(t, int64(-100), field.Integer) - } - if field.Key == "field" { - assert.Equal(t, "epEui", field.String) - } - } + entryFields := entry.FieldMap() + assert.Equal(t, int64(-100), entryFields["opId"]) + assert.Equal(t, "epEui", entryFields["field"]) break } } @@ -456,8 +449,8 @@ func TestReplayEPStatus_MissingEpStatus_ReturnsErrorAndLogs(t *testing.T) { } // Capture log output with observer at Error level - observedCore, observedLogs := observer.New(zapcore.ErrorLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures ERROR+ + testLogger := observedLogs s := &Server{ logger: testLogger, @@ -476,19 +469,14 @@ func TestReplayEPStatus_MissingEpStatus_ReturnsErrorAndLogs(t *testing.T) { assert.Contains(t, err.Error(), "missing/invalid epStatus") // Assert: LogSCACIReplayEPStatusInvalidData was logged with field="epStatus" - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("ERROR") found := false for _, entry := range logs { if entry.Message == LogSCACIReplayEPStatusInvalidData { found = true - for _, field := range entry.Context { - if field.Key == "opId" { - assert.Equal(t, int64(-101), field.Integer) - } - if field.Key == "field" { - assert.Equal(t, "epStatus", field.String) - } - } + entryFields := entry.FieldMap() + assert.Equal(t, int64(-101), entryFields["opId"]) + assert.Equal(t, "epStatus", entryFields["field"]) break } } @@ -516,8 +504,8 @@ func TestReplayEPStatus_InvalidEpEuiHex_ReturnsErrorAndLogs(t *testing.T) { } // Capture log output at Error level - observedCore, observedLogs := observer.New(zapcore.ErrorLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures ERROR+ + testLogger := observedLogs s := &Server{ logger: testLogger, @@ -535,7 +523,7 @@ func TestReplayEPStatus_InvalidEpEuiHex_ReturnsErrorAndLogs(t *testing.T) { assert.Contains(t, err.Error(), "parse epEui") // Assert: error log with epEui field - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("ERROR") found := false for _, entry := range logs { if entry.Message == LogSCACIReplayEPStatusInvalidData { @@ -568,8 +556,8 @@ func TestReplayEPStatus_InvalidNonceBase64_LogsWarning(t *testing.T) { } // Capture log output at Warn level - observedCore, observedLogs := observer.New(zapcore.WarnLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures WARN+ + testLogger := observedLogs s := &Server{ logger: testLogger, @@ -593,16 +581,13 @@ func TestReplayEPStatus_InvalidNonceBase64_LogsWarning(t *testing.T) { // Focus on warning log. // Assert: warning log for nonce field - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("WARN") found := false for _, entry := range logs { if entry.Message == LogSCACIReplayEPStatusFieldDecodeErr { found = true - for _, field := range entry.Context { - if field.Key == "field" { - assert.Equal(t, "nonce", field.String) - } - } + entryFields := entry.FieldMap() + assert.Equal(t, "nonce", entryFields["field"]) break } } @@ -635,8 +620,8 @@ func TestReplayEPStatus_InvalidSubpackets_LogsWarning(t *testing.T) { } // Capture log output at Warn level - observedCore, observedLogs := observer.New(zapcore.WarnLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures WARN+ + testLogger := observedLogs s := &Server{ logger: testLogger, @@ -655,16 +640,13 @@ func TestReplayEPStatus_InvalidSubpackets_LogsWarning(t *testing.T) { _ = serverConn.Close() // Assert: warning log for subpackets field - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("WARN") found := false for _, entry := range logs { if entry.Message == LogSCACIReplayEPStatusFieldDecodeErr { found = true - for _, field := range entry.Context { - if field.Key == "field" { - assert.Equal(t, "subpackets", field.String) - } - } + entryFields := entry.FieldMap() + assert.Equal(t, "subpackets", entryFields["field"]) break } } @@ -732,8 +714,7 @@ func TestReplayEPStatus_PreservesTelemetryFields(t *testing.T) { } // Setup logger to capture any errors - observedCore, _ := observer.New(zapcore.DebugLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + testLogger := bsscitest.NewRecordingLogger() s := &Server{ logger: testLogger, diff --git a/KC-Core/pkg/scaci/server.go b/KC-Core/pkg/scaci/server.go index 7773c23..e1fae8f 100644 --- a/KC-Core/pkg/scaci/server.go +++ b/KC-Core/pkg/scaci/server.go @@ -753,7 +753,7 @@ func (s *Server) sendErrorWithCatalog(conn net.Conn, session *Session, opId int6 posixCode, message, err = s.errorRecorder.RecordOutboundError( ctx, session, opId, CmdError, errorToken, defaultCode, detail) if err != nil { - s.logger.Warn(LogSCACIRecordEventFailed, "error", err) + s.logger.WarnContext(ctx, LogSCACIRecordEventFailed, "error", err) } } else { // Fallback to catalog lookup only (no persistence - legacy or nil session) @@ -788,7 +788,7 @@ func (s *Server) sendErrorWithCatalog(conn net.Conn, session *Session, opId int6 logFields = append(logFields, "context", detail) } - s.logger.Warn(LogSCACIErrorReceived, logFields...) + s.logger.WarnContext(ctx, LogSCACIErrorReceived, logFields...) // Send on wire (errorToken NOT included per spec - internal only) return s.sendError(conn, session, opId, posixCode, message, nil) @@ -1128,7 +1128,7 @@ func (s *Server) persistOpIDsPair(sessionID, tenantID, acOpId, scOpId int64) { ctx, cancel := context.WithTimeout(context.Background(), SessionPersistTimeout) defer cancel() if err := s.sessionRepo.UpdateOperationIDs(ctx, tid, sid, acId, scId); err != nil { - s.logger.Error(LogSCACIPersistOpIDsPairFailed, "sessionID", sid, "error", err) + s.logger.ErrorContext(ctx, LogSCACIPersistOpIDsPairFailed, "sessionID", sid, "error", err) } }(sessionID, tenantID, acOpId, scOpId) } @@ -1592,7 +1592,7 @@ func (s *Server) BroadcastEPStatus(ctx context.Context, tenantID int64, data *EP s.sessionsMu.RUnlock() if len(targets) == 0 { - s.logger.Debug(LogSCACINoActiveACsForTenant, "tenantId", tenantID) + s.logger.DebugContext(ctx, LogSCACINoActiveACsForTenant, "tenantId", tenantID) return nil // No connected ACs, not an error } @@ -1648,7 +1648,7 @@ func (s *Server) BroadcastEPStatus(ctx context.Context, tenantID int64, data *EP t.session.WriteMu.Unlock() if err != nil { - s.logger.Error(LogSCACISendEPStatusFailed, + s.logger.ErrorContext(ctx, LogSCACISendEPStatusFailed, "acEui", pkgmioty.FormatEUI64(t.session.AcEui), "error", err) return err @@ -1703,7 +1703,7 @@ func (s *Server) BroadcastEPStatus(ctx context.Context, tenantID int64, data *EP opCancel() // Cancel immediately after operation, not deferred if err != nil { - s.logger.Warn(LogSCACIRecordEPStatusOpFailed, + s.logger.WarnContext(ctx, LogSCACIRecordEPStatusOpFailed, "opId", opId, "error", err) // Continue - operation tracking is for resume, not critical path diff --git a/KC-Core/pkg/scaci/server_replay_test.go b/KC-Core/pkg/scaci/server_replay_test.go index fac1313..f673937 100644 --- a/KC-Core/pkg/scaci/server_replay_test.go +++ b/KC-Core/pkg/scaci/server_replay_test.go @@ -17,6 +17,8 @@ import ( "testing" "time" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" @@ -24,9 +26,6 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/vmihailenco/msgpack/v5" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" ) // ============================================================================ @@ -328,8 +327,8 @@ func TestReplayPendingOperations_CrossTenantRejected(t *testing.T) { } // Capture log output with observer - observedCore, observedLogs := observer.New(zapcore.DebugLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures DEBUG+ + testLogger := observedLogs session := &Session{ ID: 100, @@ -350,17 +349,14 @@ func TestReplayPendingOperations_CrossTenantRejected(t *testing.T) { s.replayPendingOperations(serverConn, session) // Assert: cross-tenant rejection logged - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("DEBUG") found := false for _, entry := range logs { if entry.Message == LogSCACICrossTenantReplayRejected { found = true // Verify context fields are present - for _, field := range entry.Context { - if field.Key == "opTenantID" { - assert.Equal(t, int64(99), field.Integer) - } - } + entryFields := entry.FieldMap() + assert.Equal(t, int64(99), entryFields["opTenantID"]) break } } @@ -616,8 +612,8 @@ func TestReplayPendingOperations_PositivePath_MatchingTenant(t *testing.T) { } // Capture log output with observer - observedCore, observedLogs := observer.New(zapcore.DebugLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures DEBUG+ + testLogger := observedLogs s := &Server{ operationRepo: mockRepo, @@ -638,7 +634,7 @@ func TestReplayPendingOperations_PositivePath_MatchingTenant(t *testing.T) { _ = serverConn.Close() // Assert: replay was logged (positive path, not rejection) - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("DEBUG") foundReplayLog := false foundRejectionLog := false for _, entry := range logs { @@ -678,8 +674,8 @@ func TestReplayULData_CorruptedUserData_LogsCorrectToken(t *testing.T) { } // Capture log output with observer at Error level - observedCore, observedLogs := observer.New(zapcore.ErrorLevel) - testLogger := logger.FromZap(zap.New(observedCore)) + observedLogs := bsscitest.NewRecordingLogger() // captures ERROR+ + testLogger := observedLogs s := &Server{ logger: testLogger, @@ -698,20 +694,15 @@ func TestReplayULData_CorruptedUserData_LogsCorrectToken(t *testing.T) { assert.Contains(t, err.Error(), "decode userData for replay") // Assert: LogSCACIReplayUserDataCorrupted was logged - logs := observedLogs.All() + logs := observedLogs.AllAtLeast("ERROR") found := false for _, entry := range logs { if entry.Message == LogSCACIReplayUserDataCorrupted { found = true // Verify context fields - for _, field := range entry.Context { - if field.Key == "opId" { - assert.Equal(t, int64(-42), field.Integer) - } - if field.Key == "storedValue" { - assert.Equal(t, "ZZZZ_NOT_HEX", field.String) - } - } + entryFields := entry.FieldMap() + assert.Equal(t, int64(-42), entryFields["opId"]) + assert.Equal(t, "ZZZZ_NOT_HEX", entryFields["storedValue"]) break } } diff --git a/KC-Core/pkg/scaci/server_test.go b/KC-Core/pkg/scaci/server_test.go index e1e1b3d..62534f0 100644 --- a/KC-Core/pkg/scaci/server_test.go +++ b/KC-Core/pkg/scaci/server_test.go @@ -14,18 +14,16 @@ package scaci import ( "testing" + bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" ) // testLogger creates a logger for testing that captures log output. func testLogger() logger.Logger { - core, _ := observer.New(zapcore.DebugLevel) - return logger.FromZap(zap.New(core)) + return bsscitest.NewRecordingLogger() } // ============================================================================ diff --git a/KC-DB/migrations/000139_restore_message_archival.down.sql b/KC-DB/migrations/000139_restore_message_archival.down.sql index 08e5933..471fe72 100644 --- a/KC-DB/migrations/000139_restore_message_archival.down.sql +++ b/KC-DB/migrations/000139_restore_message_archival.down.sql @@ -31,6 +31,21 @@ DO $$ BEGIN IF to_regclass('messages_archive_pre000139') IS NOT NULL THEN EXECUTE 'ALTER TABLE messages_archive_pre000139 RENAME TO messages_archive'; + IF to_regclass('messages_archive_pre000139_pkey') IS NOT NULL THEN + EXECUTE 'ALTER INDEX messages_archive_pre000139_pkey RENAME TO messages_archive_pkey'; + END IF; + IF to_regclass('idx_messages_archive_pre000139_ep_eui') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_pre000139_ep_eui RENAME TO idx_messages_archive_ep_eui'; + END IF; + IF to_regclass('idx_messages_archive_pre000139_tenant_id') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_pre000139_tenant_id RENAME TO idx_messages_archive_tenant_id'; + END IF; + IF to_regclass('idx_messages_archive_pre000139_received_at') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_pre000139_received_at RENAME TO idx_messages_archive_received_at'; + END IF; + IF to_regclass('idx_messages_archive_pre000139_archived_at') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_pre000139_archived_at RENAME TO idx_messages_archive_archived_at'; + END IF; RAISE NOTICE 'KC-MIG-000139-DOWN: restored preserved legacy archive'; RETURN; END IF; diff --git a/KC-DB/migrations/000139_restore_message_archival.up.sql b/KC-DB/migrations/000139_restore_message_archival.up.sql index 02dbbf7..c7d9516 100644 --- a/KC-DB/migrations/000139_restore_message_archival.up.sql +++ b/KC-DB/migrations/000139_restore_message_archival.up.sql @@ -10,6 +10,15 @@ -- cannot be losslessly projected into the 000047-era shape, so rows are kept -- queryable in place rather than re-identified. An empty legacy archive is dropped -- only after its row count is proven zero. +-- +-- Ownership contract after this migration: +-- * The canonical messages_archive receives ALL new archival writes; the +-- legacy table is never inserted into again. +-- * The legacy table preserves its original schema losslessly. +-- * Identity maintenance (EUI renames) and archival statistics cover BOTH +-- tables (repository helper updateLegacyArchiveEUI and GetArchivalStats +-- resolve the legacy table via to_regclass). +-- * The partition listing excludes both archive tables. -- Restore archival tracking columns on the live table. ALTER TABLE messages ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT false; @@ -82,6 +91,20 @@ BEGIN IF to_regclass('messages_archive_pkey') IS NOT NULL THEN EXECUTE 'ALTER INDEX messages_archive_pkey RENAME TO messages_archive_pre000139_pkey'; END IF; + -- The query indexes stay attached to the renamed table; free + -- their canonical names for the corrected table + IF to_regclass('idx_messages_archive_ep_eui') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_ep_eui RENAME TO idx_messages_archive_pre000139_ep_eui'; + END IF; + IF to_regclass('idx_messages_archive_tenant_id') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_tenant_id RENAME TO idx_messages_archive_pre000139_tenant_id'; + END IF; + IF to_regclass('idx_messages_archive_received_at') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_received_at RENAME TO idx_messages_archive_pre000139_received_at'; + END IF; + IF to_regclass('idx_messages_archive_archived_at') IS NOT NULL THEN + EXECUTE 'ALTER INDEX idx_messages_archive_archived_at RENAME TO idx_messages_archive_pre000139_archived_at'; + END IF; RAISE NOTICE 'KC-MIG-000139: preserved % legacy archive row(s) in messages_archive_pre000139', legacy_rows; ELSE -- Proven empty: nothing to preserve. diff --git a/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql b/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql index 09e4461..d669b88 100644 --- a/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql +++ b/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql @@ -1,11 +1,21 @@ --- Migration 000140: purge orphaned BSSCI pending operations by session ownership. +-- Migration 000140: purge orphaned BSSCI pending operations. -- --- Pending operations are reissued on session resume, so a row is only safe to --- delete when its owning session can never resume: a terminated session, or one --- explicitly marked non-resumable (can_resume = false). Rows of active and --- disconnected-resumable sessions are preserved. Deletability is never inferred --- from operation_type - the historical status-poll leak is reconciled at --- startup against proven-terminal sessions, not here. +-- Two independent rules: +-- +-- 1. Session ownership: pending operations are reissued on session resume, so +-- a row is safe to delete when its owning session can never resume - a +-- terminated session, or one explicitly marked non-resumable +-- (can_resume = false). Rows of active and disconnected-resumable sessions +-- are preserved regardless of type. +-- +-- 2. Historical status-poll leak: before the completion fix (commit 31db76be, +-- 2026-07-21 18:36:09+00) every status poll leaked its pending row because +-- the service center never finalized its own SC-initiated operations. +-- Status polls are idempotent liveness requests that are freshly issued on +-- every (re)connect, so pre-fix rows with operation_type = 'status' are +-- safely deleted even under resumable sessions. The boundary is the fixed +-- commit timestamp, never NOW(): the same rows are deleted no matter when +-- the migration runs. -- -- Pre/post counts by session status and operation type are emitted as notices -- for the migration evidence log. The down migration is a documented no-op: @@ -39,6 +49,19 @@ BEGIN RAISE NOTICE 'KC-MIG-000140: purged % pending operation(s) of terminated/non-resumable sessions', deletable; + -- Historical status-poll leak: pre-completion-fix status rows are + -- idempotent polls reissued on every (re)connect + SELECT COUNT(*) INTO deletable + FROM bssci_pending_operations po + WHERE po.operation_type = 'status' + AND po.created_at < TIMESTAMPTZ '2026-07-21 18:36:09+00'; + + DELETE FROM bssci_pending_operations po + WHERE po.operation_type = 'status' + AND po.created_at < TIMESTAMPTZ '2026-07-21 18:36:09+00'; + + RAISE NOTICE 'KC-MIG-000140: purged % leaked pre-fix status poll row(s)', deletable; + RAISE NOTICE 'KC-MIG-000140: pending operations by session status AFTER purge:'; FOR rec IN SELECT s.status, po.operation_type, COUNT(*) AS n diff --git a/KC-DB/storage/interfaces/repository.go b/KC-DB/storage/interfaces/repository.go index 09057ec..7829076 100644 --- a/KC-DB/storage/interfaces/repository.go +++ b/KC-DB/storage/interfaces/repository.go @@ -305,9 +305,11 @@ type PendingOperation struct { OperationType string `db:"operation_type"` EndpointEUI []byte `db:"endpoint_eui"` OperationData json.RawMessage `db:"operation_data"` - Metadata json.RawMessage `db:"metadata"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + // Metadata is []byte rather than json.RawMessage: the column is nullable, + // and database/sql can scan NULL into *[]byte but not *json.RawMessage. + Metadata []byte `db:"metadata"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } // Storage defines the main storage interface combining all repositories diff --git a/KC-DB/storage/postgres/archival.go b/KC-DB/storage/postgres/archival.go index 128c899..2b5e166 100644 --- a/KC-DB/storage/postgres/archival.go +++ b/KC-DB/storage/postgres/archival.go @@ -416,31 +416,73 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, stats.NewestMainMessage = &newestMain.Time } - // Get archive table stats + // Canonical archive stats (raw byte sizes so combined totals can be + // summed numerically; pg_size_pretty strings cannot be added) archiveQuery := ` - SELECT + SELECT COUNT(*) as total_messages, MIN(received_at) as oldest_message, MAX(received_at) as newest_message, - pg_size_pretty(pg_total_relation_size('messages_archive')) as table_size + pg_total_relation_size('messages_archive') as table_bytes FROM messages_archive` var oldestArchive, newestArchive sql.NullTime + var canonicalBytes int64 err = s.db.QueryRowContext(ctx, archiveQuery).Scan( - &stats.ArchiveTableCount, + &stats.CanonicalArchiveCount, &oldestArchive, &newestArchive, - &stats.ArchiveTableSize, + &canonicalBytes, ) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("get archive table stats: %w", err) } - if oldestArchive.Valid { - stats.OldestArchiveMessage = &oldestArchive.Time + // Legacy archive stats: the pre-000139 table preserves rows the canonical + // rebuild could not losslessly project. It is optional (to_regclass) and + // combined statistics cover both tables. + var legacyBytes int64 + var legacyExists *string + if err := s.db.QueryRowContext(ctx, `SELECT to_regclass('messages_archive_pre000139')::text`).Scan(&legacyExists); err != nil { + return nil, fmt.Errorf("check legacy archive presence: %w", err) + } + var oldestLegacy, newestLegacy sql.NullTime + if legacyExists != nil { + legacyQuery := ` + SELECT + COUNT(*) as total_messages, + MIN(received_at) as oldest_message, + MAX(received_at) as newest_message, + pg_total_relation_size('messages_archive_pre000139') as table_bytes + FROM messages_archive_pre000139` + if err := s.db.QueryRowContext(ctx, legacyQuery).Scan( + &stats.LegacyArchiveCount, + &oldestLegacy, + &newestLegacy, + &legacyBytes, + ); err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("get legacy archive stats: %w", err) + } } - if newestArchive.Valid { - stats.NewestArchiveMessage = &newestArchive.Time + + // Combined totals across both archive tables + stats.ArchiveTableCount = stats.CanonicalArchiveCount + stats.LegacyArchiveCount + stats.CanonicalArchiveSize = prettyBytes(canonicalBytes) + stats.LegacyArchiveSize = prettyBytes(legacyBytes) + stats.ArchiveTableSize = prettyBytes(canonicalBytes + legacyBytes) + + // Oldest/newest across both tables + for _, cand := range []sql.NullTime{oldestArchive, oldestLegacy} { + if cand.Valid && (stats.OldestArchiveMessage == nil || cand.Time.Before(*stats.OldestArchiveMessage)) { + t := cand.Time + stats.OldestArchiveMessage = &t + } + } + for _, cand := range []sql.NullTime{newestArchive, newestLegacy} { + if cand.Valid && (stats.NewestArchiveMessage == nil || cand.Time.After(*stats.NewestArchiveMessage)) { + t := cand.Time + stats.NewestArchiveMessage = &t + } } // Get partition info @@ -449,8 +491,9 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size FROM pg_tables - WHERE tablename LIKE 'messages_%' + WHERE tablename LIKE 'messages_%' AND tablename != 'messages_archive' + AND tablename != 'messages_archive_pre000139' ORDER BY tablename` rows, err := s.db.QueryContext(ctx, partitionQuery) @@ -475,7 +518,9 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, return stats, nil } -// ArchivalStats contains statistics about archived messages +// ArchivalStats contains statistics about archived messages. The combined +// ArchiveTableCount/ArchiveTableSize cover the canonical archive plus the +// preserved pre-000139 legacy archive; the split fields expose each side. type ArchivalStats struct { MainTableCount int64 MainTableArchived int64 @@ -483,14 +528,35 @@ type ArchivalStats struct { OldestMainMessage *time.Time NewestMainMessage *time.Time - ArchiveTableCount int64 - ArchiveTableSize string - OldestArchiveMessage *time.Time - NewestArchiveMessage *time.Time + ArchiveTableCount int64 + ArchiveTableSize string + CanonicalArchiveCount int64 + CanonicalArchiveSize string + LegacyArchiveCount int64 + LegacyArchiveSize string + OldestArchiveMessage *time.Time + NewestArchiveMessage *time.Time Partitions []PartitionInfo } +// prettyBytes renders a byte count in the pg_size_pretty style used by the +// other size fields, computed client-side so combined totals stay numeric. +func prettyBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d bytes", n) + } + units := []string{"kB", "MB", "GB", "TB", "PB"} + value := float64(n) + idx := -1 + for value >= unit && idx < len(units)-1 { + value /= unit + idx++ + } + return fmt.Sprintf("%.0f %s", value, units[idx]) +} + // PartitionInfo contains information about a message partition type PartitionInfo struct { Name string diff --git a/KC-DB/storage/postgres/archival_test.go b/KC-DB/storage/postgres/archival_test.go index e3b70f4..04e763b 100644 --- a/KC-DB/storage/postgres/archival_test.go +++ b/KC-DB/storage/postgres/archival_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "encoding/binary" "testing" "time" @@ -10,6 +9,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // archivalMessageParams describes a minimal messages row for archival tests. @@ -63,7 +64,7 @@ func TestArchivalServiceMessages(t *testing.T) { log := logger.Get() service := NewArchivalService(sqlxDB.DB, log) - ctx := context.Background() + ctx := testutil.TestContext() // High-bit EUI64 (> max int64) proves the BYTEA storage path survives the // archival round-trip without signed overflow. diff --git a/KC-DB/storage/postgres/basestation_metrics_test.go b/KC-DB/storage/postgres/basestation_metrics_test.go index f55cc22..63a864e 100644 --- a/KC-DB/storage/postgres/basestation_metrics_test.go +++ b/KC-DB/storage/postgres/basestation_metrics_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "fmt" "testing" "time" @@ -12,6 +11,8 @@ import ( "github.com/stretchr/testify/require" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // metricsWindow is a fixed UTC window used across the bucketed-read tests. @@ -83,7 +84,7 @@ func TestGetBaseStationOnlineIntervals(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() db := &DB{sqlxDB: sqlxDB} - ctx := context.Background() + ctx := testutil.TestContext() start, end := metricsWindow() createTestTenant(t, sqlxDB, 100, "TestTenant100") @@ -111,7 +112,7 @@ func TestGetBaseStationOnlineIntervals_TenantIsolation(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() db := &DB{sqlxDB: sqlxDB} - ctx := context.Background() + ctx := testutil.TestContext() start, end := metricsWindow() createTestTenant(t, sqlxDB, 200, "TestTenant200") @@ -132,7 +133,7 @@ func TestCountBaseStationMessagesByBucket_PrimaryAndSecondary(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() db := &DB{sqlxDB: sqlxDB} - ctx := context.Background() + ctx := testutil.TestContext() start, end := metricsWindow() const tenantID, bsEui int64 = 100, 5 intervalSeconds := int64(3600) @@ -160,7 +161,7 @@ func TestCountBaseStationMessagesByBucket_TenantIsolation(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() db := &DB{sqlxDB: sqlxDB} - ctx := context.Background() + ctx := testutil.TestContext() start, end := metricsWindow() insertTestMessage(t, sqlxDB, 200, 5, 1, start.Add(10*time.Minute), nil) @@ -177,7 +178,7 @@ func TestCountBaseStationMessagesByBucket_ExcludesNonUlData(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() db := &DB{sqlxDB: sqlxDB} - ctx := context.Background() + ctx := testutil.TestContext() start, end := metricsWindow() const tenantID, bsEui int64 = 100, 7 intervalSeconds := int64(3600) @@ -204,7 +205,7 @@ func TestGetBaseStationEndpointCounts_PrimaryAndSecondary(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() repo := NewMessageRepository(sqlxDB) - ctx := context.Background() + ctx := testutil.TestContext() start, _ := metricsWindow() const tenantID, bsEui int64 = 100, 5 @@ -233,7 +234,7 @@ func TestGetBaseStationLastSeen_FromMessages(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() repo := NewMessageRepository(sqlxDB) - ctx := context.Background() + ctx := testutil.TestContext() start, _ := metricsWindow() const tenantID, bsEui int64 = 100, 5 @@ -255,7 +256,7 @@ func TestGetBaseStationLastSeen_SessionFallbackAndEmpty(t *testing.T) { sqlxDB, cleanup := SetupPostgresContainer(t) defer cleanup() repo := NewMessageRepository(sqlxDB) - ctx := context.Background() + ctx := testutil.TestContext() start, _ := metricsWindow() const tenantID int64 = 100 diff --git a/KC-DB/storage/postgres/basestation_repository.go b/KC-DB/storage/postgres/basestation_repository.go index 3cac829..e8e2b6e 100644 --- a/KC-DB/storage/postgres/basestation_repository.go +++ b/KC-DB/storage/postgres/basestation_repository.go @@ -544,6 +544,12 @@ func (r *BaseStationRepository) UpdateEUI(ctx context.Context, tenantID int64, o return nil, fmt.Errorf("update messages_archive: %w", err) } + // Preserved legacy archive (pre-000139) participates in identity + // maintenance so its rows never carry a stale EUI + if err := updateLegacyArchiveEUI(ctx, tx, legacyArchiveBsEUI, newEui, oldEui); err != nil { + return nil, fmt.Errorf("update messages_archive_pre000139: %w", err) + } + // BYTEA columns: endpoints.last_attached_bs_eui _, err = tx.ExecContext(ctx, "UPDATE endpoints SET last_attached_bs_eui = $1 WHERE last_attached_bs_eui = $2", newEui, oldEui) if err != nil { diff --git a/KC-DB/storage/postgres/basestation_session_repository_test.go b/KC-DB/storage/postgres/basestation_session_repository_test.go index 312ef45..b6b3b4a 100644 --- a/KC-DB/storage/postgres/basestation_session_repository_test.go +++ b/KC-DB/storage/postgres/basestation_session_repository_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "regexp" "testing" "time" @@ -11,6 +10,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/google/uuid" "github.com/jmoiron/sqlx" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) func TestCreateSessionPersistsProtocolVersion(t *testing.T) { @@ -73,7 +74,7 @@ func TestCreateSessionPersistsProtocolVersion(t *testing.T) { ProtocolVersion: &protocolVersion, } - if _, err := repo.CreateSession(context.Background(), req); err != nil { + if _, err := repo.CreateSession(testutil.TestContext(), req); err != nil { t.Fatalf("CreateSession returned error: %v", err) } diff --git a/KC-DB/storage/postgres/blueprint_repository_det_test.go b/KC-DB/storage/postgres/blueprint_repository_det_test.go index 50e6e06..3a713ac 100644 --- a/KC-DB/storage/postgres/blueprint_repository_det_test.go +++ b/KC-DB/storage/postgres/blueprint_repository_det_test.go @@ -1,11 +1,12 @@ package postgres import ( - "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestGetByTypeEUI_Determinism validates the tie-break when duplicate type_eui rows exist: default wins, tenant beats System. @@ -17,7 +18,7 @@ func TestGetByTypeEUI_Determinism(t *testing.T) { defer func() { _ = db.Close() }() // #nosec G307 -- Test cleanup createTestTenant(t, db, 600, "TestTenant600") - ctx := context.Background() + ctx := testutil.TestContext() typeEUI := []byte{0x70, 0xb3, 0xd5, 0x9c, 0xd0, 0x00, 0x00, 0x94} var mfrID, modelID string diff --git a/KC-DB/storage/postgres/endpoint_repository.go b/KC-DB/storage/postgres/endpoint_repository.go index c68fb8e..61b963d 100644 --- a/KC-DB/storage/postgres/endpoint_repository.go +++ b/KC-DB/storage/postgres/endpoint_repository.go @@ -1374,6 +1374,12 @@ func (r *EndPointRepository) UpdateWithEUI(ctx context.Context, tenantID int64, return nil, fmt.Errorf("update messages_archive.ep_eui: %w", err) } + // Preserved legacy archive (pre-000139) participates in identity + // maintenance so its rows never carry a stale EUI + if err := updateLegacyArchiveEUI(ctx, tx, legacyArchiveEpEUI, newEui, oldEui); err != nil { + return nil, fmt.Errorf("update messages_archive_pre000139.ep_eui: %w", err) + } + // BYTEA columns: mioty_message_deduplication.ep_eui _, err = tx.ExecContext(ctx, "UPDATE mioty_message_deduplication SET ep_eui = $1 WHERE ep_eui = $2", newEui, oldEui) if err != nil { diff --git a/KC-DB/storage/postgres/endpoint_repository_test.go b/KC-DB/storage/postgres/endpoint_repository_test.go index 6599573..1c4a4ff 100644 --- a/KC-DB/storage/postgres/endpoint_repository_test.go +++ b/KC-DB/storage/postgres/endpoint_repository_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "database/sql" "encoding/json" "errors" @@ -1582,7 +1581,7 @@ func TestStreamAllForPropagation_UsesCorrectColumnName(t *testing.T) { WillReturnRows(rows) // Execute StreamAllForPropagation - ctx := context.Background() + ctx := testutil.TestContext() endpoints, err := repo.StreamAllForPropagation(ctx, 0, 100) require.NoError(t, err, "StreamAllForPropagation should succeed") require.Len(t, endpoints, 1, "Should return 1 endpoint") @@ -1651,7 +1650,7 @@ func TestStreamAllForPropagation_QueryTextValidation(t *testing.T) { mock.ExpectQuery("").WillReturnRows(rows) // Execute - the custom matcher will verify column names - ctx := context.Background() + ctx := testutil.TestContext() _, err = repo.StreamAllForPropagation(ctx, 0, 100) require.NoError(t, err, "StreamAllForPropagation should succeed with correct column name") diff --git a/KC-DB/storage/postgres/endpoint_session_migration_test.go b/KC-DB/storage/postgres/endpoint_session_migration_test.go index 5de8397..93051ca 100644 --- a/KC-DB/storage/postgres/endpoint_session_migration_test.go +++ b/KC-DB/storage/postgres/endpoint_session_migration_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "encoding/base64" "fmt" "testing" @@ -12,6 +11,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // Test lazy migration from base64-encoded GCM to raw GCM format in GetActive() @@ -21,7 +22,7 @@ func TestGetActive_LazyMigration(t *testing.T) { t.Skip("Skipping integration test in short mode") } - ctx := context.Background() + ctx := testutil.TestContext() db := setupTestDB(t) defer cleanupTestDB(t, db) @@ -110,7 +111,7 @@ func TestGetActive_NoMigrationWhenAlreadyRaw(t *testing.T) { t.Skip("Skipping integration test in short mode") } - ctx := context.Background() + ctx := testutil.TestContext() db := setupTestDB(t) defer cleanupTestDB(t, db) @@ -185,7 +186,7 @@ func TestGetActive_TenantIsolationInMigration(t *testing.T) { t.Skip("Skipping integration test in short mode") } - ctx := context.Background() + ctx := testutil.TestContext() db := setupTestDB(t) defer cleanupTestDB(t, db) @@ -309,7 +310,7 @@ func cleanupTestDB(t *testing.T, db *DB) { t.Helper() // Clean up test data - ctx := context.Background() + ctx := testutil.TestContext() // Delete test sessions _, err := db.conn.ExecContext(ctx, ` diff --git a/KC-DB/storage/postgres/legacy_archive.go b/KC-DB/storage/postgres/legacy_archive.go new file mode 100644 index 0000000..a20c172 --- /dev/null +++ b/KC-DB/storage/postgres/legacy_archive.go @@ -0,0 +1,47 @@ +package postgres + +import ( + "context" + "fmt" +) + +// legacyArchiveEUIColumn enumerates the only EUI columns the preserved +// pre-000139 legacy archive shares with the canonical archive. The closed +// switch keeps every SQL identifier fixed in this file - callers can never +// supply table or column names. +type legacyArchiveEUIColumn int + +const ( + legacyArchiveBsEUI legacyArchiveEUIColumn = iota + legacyArchiveEpEUI +) + +// updateLegacyArchiveEUI applies an EUI rename to the preserved legacy +// archive so identity maintenance covers both archive tables. The table is +// optional: a database whose legacy archive was empty (and therefore dropped +// by migration 000139) is a no-op, resolved via to_regclass inside the same +// transaction as the canonical rename. +func updateLegacyArchiveEUI(ctx context.Context, q sqlExecQuerier, column legacyArchiveEUIColumn, newEUI, oldEUI []byte) error { + var exists *string + if err := q.QueryRowContext(ctx, `SELECT to_regclass('messages_archive_pre000139')::text`).Scan(&exists); err != nil { + return fmt.Errorf("check legacy archive presence: %w", err) + } + if exists == nil { + return nil + } + + var stmt string + switch column { + case legacyArchiveBsEUI: + stmt = `UPDATE messages_archive_pre000139 SET bs_eui = $1 WHERE bs_eui = $2` + case legacyArchiveEpEUI: + stmt = `UPDATE messages_archive_pre000139 SET ep_eui = $1 WHERE ep_eui = $2` + default: + return fmt.Errorf("unknown legacy archive column %d", column) + } + + if _, err := q.ExecContext(ctx, stmt, newEUI, oldEUI); err != nil { + return fmt.Errorf("update legacy archive: %w", err) + } + return nil +} diff --git a/KC-DB/storage/postgres/migration_000139_000140_test.go b/KC-DB/storage/postgres/migration_000139_000140_test.go new file mode 100644 index 0000000..afa69af --- /dev/null +++ b/KC-DB/storage/postgres/migration_000139_000140_test.go @@ -0,0 +1,227 @@ +package postgres + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/golang-migrate/migrate/v4" + migratepostgres "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newMigrator builds a migrate instance over the container database. +func newMigrator(t *testing.T, db *sqlx.DB) *migrate.Migrate { + t.Helper() + migrationsDir, err := filepath.Abs("../../migrations") + require.NoError(t, err) + + driver, err := migratepostgres.WithInstance(db.DB, &migratepostgres.Config{}) + require.NoError(t, err) + + m, err := migrate.NewWithDatabaseInstance( + fmt.Sprintf("file://%s", filepath.ToSlash(migrationsDir)), + "postgres", driver) + require.NoError(t, err) + return m +} + +// seedLegacyArchiveRow inserts a row into the pre-000139 legacy archive +// layout (001-era messages columns). +func seedLegacyArchiveRow(t *testing.T, db *sqlx.DB, id int64, epEUI, bsEUI []byte, frameCount int) { + t.Helper() + _, err := db.Exec(` + INSERT INTO messages_archive ( + id, ep_eui, bs_eui, tenant_id, payload, frame_count, + rssi, snr, eq_snr, frequency, received_at + ) VALUES ($1, $2, $3, 1, $4, $5, -80, 10, 9.5, 868300000, NOW())`, + id, epEUI, bsEUI, []byte{0x01, 0x02}, frameCount) + require.NoError(t, err, "seed legacy archive row") +} + +// TestMigration000139LegacyPreservation drives up/down/up with a POPULATED +// legacy archive: rows (including high-bit EUIs) survive field-for-field +// under messages_archive_pre000139, EUI maintenance and archival statistics +// cover both tables, down restores the original table, and a second up +// preserves it again without duplication or loss. +func TestMigration000139LegacyPreservation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping migration test in short mode") + } + db, _, cleanup := SetupPostgresContainerWithoutMigrations(t) + defer cleanup() + m := newMigrator(t, db) + ctx := testutil.TestContext() + + // Schema state immediately before the archive rebuild + require.NoError(t, m.Migrate(138), "migrate to 000138") + + // High-bit EUIs prove BYTEA rows survive without numeric projection + highEp := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE} + highBs := []byte{0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01} + lowEp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02} + lowBs := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03} + seedLegacyArchiveRow(t, db, 1, highEp, highBs, 42) + seedLegacyArchiveRow(t, db, 2, lowEp, lowBs, 7) + + // Up: rebuild preserves the populated legacy table + require.NoError(t, m.Migrate(139), "migrate to 000139") + + var legacyCount int + require.NoError(t, db.Get(&legacyCount, `SELECT COUNT(*) FROM messages_archive_pre000139`)) + assert.Equal(t, 2, legacyCount, "every legacy row must be preserved") + + var frameCount int + require.NoError(t, db.Get(&frameCount, + `SELECT frame_count FROM messages_archive_pre000139 WHERE ep_eui = $1`, highEp)) + assert.Equal(t, 42, frameCount, "high-bit EUI row fields must survive intact") + + var canonicalCount int + require.NoError(t, db.Get(&canonicalCount, `SELECT COUNT(*) FROM messages_archive`)) + assert.Zero(t, canonicalCount, "the corrected canonical archive starts empty") + + // EUI maintenance covers the legacy table via the shared helper + renamedBs := []byte{0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99} + require.NoError(t, updateLegacyArchiveEUI(ctx, db, legacyArchiveBsEUI, renamedBs, highBs)) + var renamedCount int + require.NoError(t, db.Get(&renamedCount, + `SELECT COUNT(*) FROM messages_archive_pre000139 WHERE bs_eui = $1`, renamedBs)) + assert.Equal(t, 1, renamedCount, "legacy archive must follow BS EUI renames") + + // Archival statistics cover both tables: combined plus split counts + svc := NewArchivalService(db.DB, logger.NewNop()) + stats, err := svc.GetArchivalStats(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), stats.LegacyArchiveCount) + assert.Equal(t, int64(0), stats.CanonicalArchiveCount) + assert.Equal(t, int64(2), stats.ArchiveTableCount, "combined count covers both tables") + assert.NotEmpty(t, stats.LegacyArchiveSize) + assert.NotNil(t, stats.OldestArchiveMessage, "oldest spans both tables") + for _, p := range stats.Partitions { + assert.NotEqual(t, "messages_archive", p.Name) + assert.NotEqual(t, "messages_archive_pre000139", p.Name, + "archive tables are not message partitions") + } + + // Down (canonical archive empty): the preserved table is restored + require.NoError(t, m.Migrate(138), "migrate down to 000138") + require.NoError(t, db.Get(&legacyCount, `SELECT COUNT(*) FROM messages_archive`)) + assert.Equal(t, 2, legacyCount, "down restores the preserved legacy archive") + var preExists *string + require.NoError(t, db.Get(&preExists, `SELECT to_regclass('messages_archive_pre000139')::text`)) + assert.Nil(t, preExists, "the preserved name is released on down") + + // Up again: preserved once more, no duplication or loss + require.NoError(t, m.Migrate(139), "migrate up to 000139 again") + require.NoError(t, db.Get(&legacyCount, `SELECT COUNT(*) FROM messages_archive_pre000139`)) + assert.Equal(t, 2, legacyCount, "second up preserves the same rows exactly once") +} + +// TestMigration000139DownRefusesWithCanonicalRows verifies the down migration +// blocks with its named diagnostic when the canonical archive holds rows, +// instead of silently destroying archived messages. +func TestMigration000139DownRefusesWithCanonicalRows(t *testing.T) { + if testing.Short() { + t.Skip("Skipping migration test in short mode") + } + db, _, cleanup := SetupPostgresContainerWithoutMigrations(t) + defer cleanup() + m := newMigrator(t, db) + + require.NoError(t, m.Migrate(139), "migrate to 000139") + + _, err := db.Exec(` + INSERT INTO messages_archive ( + tenant_id, op_id, ep_eui, bs_eui, rx_time, packet_cnt, snr, rssi + ) VALUES (1, 1, $1, $2, 1, 1, 10, -80)`, + []byte{0, 0, 0, 0, 0, 0, 0, 1}, []byte{0, 0, 0, 0, 0, 0, 0, 2}) + require.NoError(t, err, "seed canonical archive row") + + err = m.Migrate(138) + require.Error(t, err, "down with canonical archive rows must refuse") + assert.Contains(t, err.Error(), "KC-MIG-000139-DOWN", + "the refusal must carry its named diagnostic") +} + +// TestMigration000140PurgeRules verifies both purge rules: session-ownership +// deletion for terminated/non-resumable sessions, and the fixed-cutoff purge +// of pre-completion-fix status polls - while every other operation of a +// disconnected-but-resumable session survives. +func TestMigration000140PurgeRules(t *testing.T) { + if testing.Short() { + t.Skip("Skipping migration test in short mode") + } + db, _, cleanup := SetupPostgresContainerWithoutMigrations(t) + defer cleanup() + m := newMigrator(t, db) + + require.NoError(t, m.Migrate(139), "migrate to 000139") + + // Seed tenant → base station → sessions + var tenantID int64 + require.NoError(t, db.QueryRow(` + INSERT INTO tenants (name, status, created_at, updated_at) + VALUES ('mig140', 'active', NOW(), NOW()) RETURNING id`).Scan(&tenantID)) + + var bsID int64 + require.NoError(t, db.QueryRow(` + INSERT INTO basestations (tenant_id, bs_eui, name, connection_type, service_center_url) + VALUES ($1, $2, 'mig140-bs', 'bssci', 'bssci://test') RETURNING id`, + tenantID, []byte{0, 0, 0, 0, 0, 0, 0, 9}).Scan(&bsID)) + + bsUUID := make([]byte, 16) + scUUID := make([]byte, 16) + insertSession := func(status string, canResume bool, mark byte) int64 { + bsUUID[15] = mark + scUUID[15] = mark + var id int64 + require.NoError(t, db.QueryRow(` + INSERT INTO basestation_sessions ( + basestation_id, tenant_id, sn_bs_uuid, sn_sc_uuid, sn_bs_op_id, sn_sc_op_id, + status, can_resume, encoding, started_at + ) VALUES ($1, $2, $3, $4, 0, 0, $5, $6, 'msgpack', NOW()) RETURNING id`, + bsID, tenantID, bsUUID, scUUID, status, canResume).Scan(&id)) + return id + } + resumable := insertSession("disconnected", true, 1) + terminated := insertSession("terminated", false, 2) + + cutoff := time.Date(2026, 7, 21, 18, 36, 9, 0, time.UTC) + insertOp := func(sessionID, opID int64, opType string, createdAt time.Time) { + _, err := db.Exec(` + INSERT INTO bssci_pending_operations ( + basestation_session_id, operation_id, operation_type, operation_data, created_at + ) VALUES ($1, $2, $3, '{}', $4)`, + sessionID, opID, opType, createdAt) + require.NoError(t, err, "seed pending op") + } + insertOp(resumable, -1, "status", cutoff.Add(-time.Hour)) // pre-fix leak: purged + insertOp(resumable, -2, "status", cutoff.Add(time.Hour)) // post-fix poll: survives + insertOp(resumable, -3, "dlDataQue", cutoff.Add(-time.Hour)) // non-status: survives + insertOp(terminated, -4, "dlDataQue", cutoff.Add(time.Hour)) // terminated session: purged + + require.NoError(t, m.Migrate(140), "migrate to 000140") + + surviving := map[int64]bool{} + rows, err := db.Query(`SELECT operation_id FROM bssci_pending_operations`) + require.NoError(t, err) + defer func() { _ = rows.Close() }() + for rows.Next() { + var id int64 + require.NoError(t, rows.Scan(&id)) + surviving[id] = true + } + require.NoError(t, rows.Err()) + + assert.False(t, surviving[-1], "pre-fix leaked status poll must be purged") + assert.True(t, surviving[-2], "post-fix status poll of a resumable session must survive") + assert.True(t, surviving[-3], "non-status operation of a resumable session must survive") + assert.False(t, surviving[-4], "operations of a terminated session must be purged") + assert.Len(t, surviving, 2) +} diff --git a/KC-DB/storage/postgres/pending_operation_repository_test.go b/KC-DB/storage/postgres/pending_operation_repository_test.go new file mode 100644 index 0000000..7f878c4 --- /dev/null +++ b/KC-DB/storage/postgres/pending_operation_repository_test.go @@ -0,0 +1,56 @@ +package postgres + +import ( + "encoding/json" + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetBySession_NullMetadataScans verifies rows persisted without metadata +// (a nullable column) load during session resume instead of failing the scan. +func TestGetBySession_NullMetadataScans(t *testing.T) { + if testing.Short() { + t.Skip("Skipping container test in short mode") + } + db, cleanup := SetupPostgresContainer(t) + defer cleanup() + ctx := testutil.TestContext() + + var tenantID int64 + require.NoError(t, db.QueryRow(` + INSERT INTO tenants (name, status, created_at, updated_at) + VALUES ('pending-null-md', 'active', NOW(), NOW()) RETURNING id`).Scan(&tenantID)) + + var bsID int64 + require.NoError(t, db.QueryRow(` + INSERT INTO basestations (tenant_id, bs_eui, name, connection_type, service_center_url) + VALUES ($1, $2, 'pending-null-md-bs', 'bssci', 'bssci://test') RETURNING id`, + tenantID, []byte{0, 0, 0, 0, 0, 0, 0, 7}).Scan(&bsID)) + + var sessionID int64 + require.NoError(t, db.QueryRow(` + INSERT INTO basestation_sessions ( + basestation_id, tenant_id, sn_bs_uuid, sn_sc_uuid, sn_bs_op_id, sn_sc_op_id, + status, can_resume, encoding, started_at + ) VALUES ($1, $2, $3, $4, 0, 0, 'disconnected', true, 'msgpack', NOW()) RETURNING id`, + bsID, tenantID, make([]byte, 16), make([]byte, 16)).Scan(&sessionID)) + + repo := NewPendingOperationRepository(db, logger.NewNop()) + require.NoError(t, repo.Create(ctx, &interfaces.PendingOperationRequest{ + SessionID: sessionID, + OperationID: -1, + OperationType: "status", + OperationData: json.RawMessage(`{"command":"status","opId":-1}`), + }), "create pending operation without metadata") + + ops, err := repo.GetBySession(ctx, sessionID) + require.NoError(t, err, "NULL metadata must scan during resume load") + require.Len(t, ops, 1) + assert.Nil(t, ops[0].Metadata, "absent metadata loads as nil") + assert.Equal(t, int64(-1), ops[0].OperationID) +} diff --git a/KC-DB/storage/postgres/roaming_repository_test.go b/KC-DB/storage/postgres/roaming_repository_test.go index 9e3e7cb..d8622a0 100644 --- a/KC-DB/storage/postgres/roaming_repository_test.go +++ b/KC-DB/storage/postgres/roaming_repository_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "encoding/json" "testing" "time" @@ -13,6 +12,8 @@ import ( "github.com/stretchr/testify/require" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // setupRoamingTestDB creates a test database with minimal schema for roaming tests @@ -165,7 +166,7 @@ func TestAddRoamingEndpointToSession_NullArray(t *testing.T) { // Create tenant required by FK constraints createTestTenant(t, db, 1, "TestTenant1") - ctx := context.Background() + ctx := testutil.TestContext() repo := NewRoamingRepository(db) // Create session with NULL roaming_endpoints @@ -201,7 +202,7 @@ func TestAddRoamingEndpointToSession_NullCounter(t *testing.T) { // Create tenant required by FK constraints createTestTenant(t, db, 1, "TestTenant1") - ctx := context.Background() + ctx := testutil.TestContext() repo := NewRoamingRepository(db) // Create basestation first (required FK) @@ -255,7 +256,7 @@ func TestRemoveRoamingEndpointFromSession_ElementExists(t *testing.T) { // Create tenant required by FK constraints createTestTenant(t, db, 1, "TestTenant1") - ctx := context.Background() + ctx := testutil.TestContext() repo := NewRoamingRepository(db) // Create session with 2 roaming endpoints @@ -299,7 +300,7 @@ func TestRemoveRoamingEndpointFromSession_ElementNotExists(t *testing.T) { // Create tenant required by FK constraints createTestTenant(t, db, 1, "TestTenant1") - ctx := context.Background() + ctx := testutil.TestContext() repo := NewRoamingRepository(db) // Create session with 2 roaming endpoints @@ -343,7 +344,7 @@ func TestRemoveRoamingEndpointFromSession_EmptyArray(t *testing.T) { // Create tenant required by FK constraints createTestTenant(t, db, 1, "TestTenant1") - ctx := context.Background() + ctx := testutil.TestContext() repo := NewRoamingRepository(db) // Create session with empty roaming array diff --git a/KC-DB/storage/postgres/scaci_operation_repository_test.go b/KC-DB/storage/postgres/scaci_operation_repository_test.go index 0d2f0f5..dd9bcf0 100644 --- a/KC-DB/storage/postgres/scaci_operation_repository_test.go +++ b/KC-DB/storage/postgres/scaci_operation_repository_test.go @@ -10,6 +10,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // setupSCACIOperationTestDB creates a test database with testcontainers @@ -33,7 +35,7 @@ func createSCACIOperationTestSession(t *testing.T, db *sqlx.DB, tenantID int64) t.Helper() sessionRepo := NewSCACISessionRepository(db) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() createReq := &models.SCACISessionCreateRequest{ @@ -88,7 +90,7 @@ func TestRecordOperation_DeregisterEpEui_Roundtrip(t *testing.T) { defer cleanupSCACIOperationTestSession(t, db, testSessionID) repo := NewSCACIOperationRepository(db, logger.Get().WithField("component", "scaci_operation_repository_test")) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Test case: numeric uint64 epEui for propagation lookup @@ -149,7 +151,7 @@ func TestRecordOperation_CleanupMetadata_Roundtrip(t *testing.T) { defer cleanupSCACIOperationTestSession(t, db, testSessionID) repo := NewSCACIOperationRepository(db, logger.Get().WithField("component", "scaci_operation_repository_test")) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Record initial operation @@ -218,7 +220,7 @@ func TestUpdateOperationState_CompletedWithWarnings(t *testing.T) { defer cleanupSCACIOperationTestSession(t, db, testSessionID) repo := NewSCACIOperationRepository(db, logger.Get().WithField("component", "scaci_operation_repository_test")) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Record initial operation in pending state diff --git a/KC-DB/storage/postgres/scaci_session_guard_test.go b/KC-DB/storage/postgres/scaci_session_guard_test.go index ef9febb..59fe786 100644 --- a/KC-DB/storage/postgres/scaci_session_guard_test.go +++ b/KC-DB/storage/postgres/scaci_session_guard_test.go @@ -1,15 +1,16 @@ package postgres import ( - "context" "testing" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) func TestSCACIListSessions_NilTenantID_ReturnsError(t *testing.T) { repo := &SCACISessionRepository{db: nil} - _, _, err := repo.ListSessions(context.Background(), &models.SCACISessionFilter{}) + _, _, err := repo.ListSessions(testutil.TestContext(), &models.SCACISessionFilter{}) if err == nil { t.Fatal("expected error for nil tenant ID, got nil") } @@ -20,7 +21,7 @@ func TestSCACIListSessions_NilTenantID_ReturnsError(t *testing.T) { func TestSCACIListSessions_NilFilter_ReturnsError(t *testing.T) { repo := &SCACISessionRepository{db: nil} - _, _, err := repo.ListSessions(context.Background(), nil) + _, _, err := repo.ListSessions(testutil.TestContext(), nil) if err == nil { t.Fatal("expected error for nil filter (nil tenant ID), got nil") } diff --git a/KC-DB/storage/postgres/scaci_session_repository_test.go b/KC-DB/storage/postgres/scaci_session_repository_test.go index c1bb85d..ec2a73e 100644 --- a/KC-DB/storage/postgres/scaci_session_repository_test.go +++ b/KC-DB/storage/postgres/scaci_session_repository_test.go @@ -9,6 +9,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // setupSCACISessionTestDB connects via testcontainers @@ -37,7 +39,7 @@ func TestSCACISessionRepository_CreateSession_WithTLS(t *testing.T) { createSCACITestTenant(t, db, 100, "TestTenant100") repo := NewSCACISessionRepository(db) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() tlsVer := "TLS 1.3" @@ -78,7 +80,7 @@ func TestSCACISessionRepository_UpdateSession_TLSFields(t *testing.T) { createSCACITestTenant(t, db, 101, "TestTenant101") repo := NewSCACISessionRepository(db) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() initialTLS := "TLS 1.2" @@ -128,7 +130,7 @@ func TestSCACISessionRepository_UpdateSession_PartialTLS(t *testing.T) { createSCACITestTenant(t, db, 102, "TestTenant102") repo := NewSCACISessionRepository(db) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() initialTLS := "TLS 1.2" @@ -185,7 +187,7 @@ func TestSCACISessionRepository_CheckSessionResumable_TenantFilter(t *testing.T) createSCACITestTenant(t, db, 202, "TenantB_Attacker") repo := NewSCACISessionRepository(db) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Create session owned by tenant 201 (victim) @@ -235,7 +237,7 @@ func TestSCACISessionRepository_GetSessionByAcUUID_TenantScoped(t *testing.T) { createSCACITestTenant(t, db, 204, "TenantD_Other") repo := NewSCACISessionRepository(db) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Create session owned by tenant 203 @@ -281,7 +283,7 @@ func TestSCACISessionRepository_GetSessionByID_TenantScoped(t *testing.T) { createSCACITestTenant(t, db, 206, "TenantF_Other") repo := NewSCACISessionRepository(db) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Create session owned by tenant 205 diff --git a/KC-DB/storage/postgres/system_events_guard_test.go b/KC-DB/storage/postgres/system_events_guard_test.go index 46b3226..ba0bada 100644 --- a/KC-DB/storage/postgres/system_events_guard_test.go +++ b/KC-DB/storage/postgres/system_events_guard_test.go @@ -1,16 +1,17 @@ package postgres import ( - "context" "testing" "time" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) func TestGetEvents_EmptyTenantID_ReturnsError(t *testing.T) { store := NewSystemEventStore(nil) - _, err := store.GetEvents(context.Background(), interfaces.SystemEventFilter{}) + _, err := store.GetEvents(testutil.TestContext(), interfaces.SystemEventFilter{}) if err == nil { t.Fatal("expected error for empty tenant ID, got nil") } @@ -21,7 +22,7 @@ func TestGetEvents_EmptyTenantID_ReturnsError(t *testing.T) { func TestCountEvents_EmptyTenantID_ReturnsError(t *testing.T) { store := NewSystemEventStore(nil) - _, err := store.CountEvents(context.Background(), interfaces.SystemEventFilter{}) + _, err := store.CountEvents(testutil.TestContext(), interfaces.SystemEventFilter{}) if err == nil { t.Fatal("expected error for empty tenant ID, got nil") } @@ -32,7 +33,7 @@ func TestCountEvents_EmptyTenantID_ReturnsError(t *testing.T) { func TestCountActiveAlerts_EmptyTenantID_ReturnsError(t *testing.T) { store := NewSystemEventStore(nil) - _, err := store.CountActiveAlerts(context.Background(), interfaces.AlertFilter{}) + _, err := store.CountActiveAlerts(testutil.TestContext(), interfaces.AlertFilter{}) if err == nil { t.Fatal("expected error for empty tenant ID, got nil") } @@ -43,7 +44,7 @@ func TestCountActiveAlerts_EmptyTenantID_ReturnsError(t *testing.T) { func TestGetActiveAlerts_EmptyTenantID_ReturnsError(t *testing.T) { store := NewSystemEventStore(nil) - _, err := store.GetActiveAlerts(context.Background(), interfaces.AlertFilter{}) + _, err := store.GetActiveAlerts(testutil.TestContext(), interfaces.AlertFilter{}) if err == nil { t.Fatal("expected error for empty tenant ID, got nil") } @@ -54,7 +55,7 @@ func TestGetActiveAlerts_EmptyTenantID_ReturnsError(t *testing.T) { func TestGetEventStats_EmptyTenantID_ReturnsError(t *testing.T) { store := NewSystemEventStore(nil) - _, err := store.GetEventStats(context.Background(), "", time.Now()) + _, err := store.GetEventStats(testutil.TestContext(), "", time.Now()) if err == nil { t.Fatal("expected error for empty tenant ID, got nil") } diff --git a/KC-DB/storage/postgres/system_events_test.go b/KC-DB/storage/postgres/system_events_test.go index 703e4e8..3135d6b 100644 --- a/KC-DB/storage/postgres/system_events_test.go +++ b/KC-DB/storage/postgres/system_events_test.go @@ -11,6 +11,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // setupSystemEventsTestDB creates a test database with testcontainers @@ -68,7 +70,7 @@ func TestGetEvents_TenantIsolation(t *testing.T) { insertTestEvent(t, db, tenantB, "endpoint.attached", "endpoint", "EP Attached B1", now, nil) store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Query for tenant A only @@ -114,7 +116,7 @@ func TestGetEvents_SinceFilter(t *testing.T) { insertTestEvent(t, db, tenantID, "event.recent", "system", "Recent Event", now.Add(-10*time.Second), nil) store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Query events since 20 seconds ago @@ -149,7 +151,7 @@ func TestGetEvents_LimitFilter(t *testing.T) { } store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Query with limit of 3 @@ -181,7 +183,7 @@ func TestGetEvents_CombinedFilters(t *testing.T) { insertTestEvent(t, db, tenantID, "event.recent", "system", "Recent System", now.Add(-5*time.Second), nil) store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Query: tenant + since + category @@ -210,7 +212,7 @@ func TestGetEvents_EmptyResult(t *testing.T) { createSystemEventsTestTenant(t, db, tenantID, "EmptyResultTest") store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Query for non-existent tenant @@ -232,7 +234,7 @@ func TestGetEvents_InvalidTenantID(t *testing.T) { defer func() { _ = db.Close() }() store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Query with invalid tenant ID (non-numeric) @@ -268,7 +270,7 @@ func TestGetEvents_JSONBDataUnmarshal(t *testing.T) { insertTestEvent(t, db, tenantID, "endpoint.attached", "endpoint", "EP Attached", now, testData) store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() events, err := store.GetEvents(ctx, interfaces.SystemEventFilter{ @@ -308,7 +310,7 @@ func TestGetEvents_OrderByDefault(t *testing.T) { insertTestEvent(t, db, tenantID, "event.newest", "system", "Newest", now.Add(-1*time.Second), nil) store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() events, err := store.GetEvents(ctx, interfaces.SystemEventFilter{ @@ -337,7 +339,7 @@ func TestGetEvents_CreateEventRoundtrip(t *testing.T) { createSystemEventsTestTenant(t, db, tenantID, "RoundtripTest") store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Create event using the store's CreateEvent method @@ -391,7 +393,7 @@ func TestGetEvents_MultipleCategories_IncludesProtocol(t *testing.T) { insertTestEvent(t, db, tenantID, "test.message", models.EventCategoryMessage, "Message Event", now.Add(-3*time.Second), nil) store := NewSystemEventStore(db.DB) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(testutil.TestContext(), 5*time.Second) defer cancel() // Filter by protocol categories only diff --git a/KC-DB/storage/postgres/testcontainer_test.go b/KC-DB/storage/postgres/testcontainer_test.go index b212d39..986d0ef 100644 --- a/KC-DB/storage/postgres/testcontainer_test.go +++ b/KC-DB/storage/postgres/testcontainer_test.go @@ -1,7 +1,6 @@ package postgres import ( - "context" "database/sql" "fmt" "net/url" @@ -19,6 +18,8 @@ import ( "github.com/testcontainers/testcontainers-go" testcontainerspostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // TestContainerConfig holds parsed connection details from testcontainer DSN @@ -34,7 +35,7 @@ type TestContainerConfig struct { // Useful for migration tests that need to test applying migrations themselves // Returns *sqlx.DB connection, parsed config with dynamic port, and cleanup function func SetupPostgresContainerWithoutMigrations(t *testing.T) (*sqlx.DB, *TestContainerConfig, func()) { - ctx := context.Background() + ctx := testutil.TestContext() // Start PostgreSQL container postgresContainer, err := testcontainerspostgres.Run(ctx, @@ -78,7 +79,7 @@ func SetupPostgresContainerWithoutMigrations(t *testing.T) (*sqlx.DB, *TestConta // Returns *sqlx.DB connection and cleanup function // The DSN is parsed to extract dynamic host/port for CI compatibility func SetupPostgresContainer(t *testing.T) (*sqlx.DB, func()) { - ctx := context.Background() + ctx := testutil.TestContext() // Start PostgreSQL container postgresContainer, err := testcontainerspostgres.Run(ctx, diff --git a/KC-Gateway/internal/integration_test.go b/KC-Gateway/internal/integration_test.go index 8b12c44..d8ef7ad 100644 --- a/KC-Gateway/internal/integration_test.go +++ b/KC-Gateway/internal/integration_test.go @@ -28,6 +28,8 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // Shared test fixtures @@ -253,7 +255,7 @@ func TestGatewayTwoHop_AuthenticatedRequest(t *testing.T) { "x-user-id", testUserUUID.String(), "x-kc-internal-tenant-id", "999", // spoofed — must be stripped ) - ctx := metadata.NewOutgoingContext(context.Background(), md) + ctx := metadata.NewOutgoingContext(testutil.TestContext(), md) _, err = client.ListEndPoints(ctx, &kilocenterv1.ListEndPointsRequest{}) require.NoError(t, err, "ListEndPoints should succeed through gateway") @@ -295,7 +297,7 @@ func TestGatewayTwoHop_UnauthenticatedRequest(t *testing.T) { client := kilocenterv1.NewCoreServiceClient(conn) // Send request with NO authorization header - _, err = client.ListEndPoints(context.Background(), &kilocenterv1.ListEndPointsRequest{}) + _, err = client.ListEndPoints(testutil.TestContext(), &kilocenterv1.ListEndPointsRequest{}) require.Error(t, err, "unauthenticated request should fail") st, ok := status.FromError(err) @@ -326,7 +328,7 @@ func TestGatewayTwoHop_TraceContextPropagation(t *testing.T) { "x-user-id", testUserUUID.String(), "traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", ) - ctx := metadata.NewOutgoingContext(context.Background(), md) + ctx := metadata.NewOutgoingContext(testutil.TestContext(), md) _, err = client.ListEndPoints(ctx, &kilocenterv1.ListEndPointsRequest{}) require.NoError(t, err, "ListEndPoints should succeed with traceparent") @@ -463,7 +465,7 @@ func TestCommunityMode_AuthenticatedTenantPreserved(t *testing.T) { "x-organization-id", testOrgUUID.String(), "x-user-id", testUserUUID.String(), ) - ctx := metadata.NewOutgoingContext(context.Background(), md) + ctx := metadata.NewOutgoingContext(testutil.TestContext(), md) _, err = client.ListEndPoints(ctx, &kilocenterv1.ListEndPointsRequest{}) require.NoError(t, err, "ListEndPoints should succeed") @@ -498,7 +500,7 @@ func TestCommunityMode_FallbackOnlyWhenTenantMissing(t *testing.T) { // GetReleaseInfo is a public method — skips auth entirely. // Without auth, no tenant is set in context, so director should inject default. client := kilocenterv1.NewCoreServiceClient(conn) - _, err = client.GetReleaseInfo(context.Background(), &emptypb.Empty{}) + _, err = client.GetReleaseInfo(testutil.TestContext(), &emptypb.Empty{}) require.NoError(t, err, "GetReleaseInfo (public) should succeed without auth") assert.Equal(t, int64(1), setup.captured.callCount.Load(), "upstream called once") @@ -533,7 +535,7 @@ func TestStrictMode_MissingOrgHeaderRejected(t *testing.T) { "authorization", "Bearer "+rawAPIKey, "x-user-id", testUserUUID.String(), ) - ctx := metadata.NewOutgoingContext(context.Background(), md) + ctx := metadata.NewOutgoingContext(testutil.TestContext(), md) _, err = client.ListEndPoints(ctx, &kilocenterv1.ListEndPointsRequest{}) require.Error(t, err, "missing org header should fail in strict mode") @@ -562,7 +564,7 @@ func TestPublicMethodWorksRegardless(t *testing.T) { defer func() { _ = conn.Close() }() client := kilocenterv1.NewCoreServiceClient(conn) - _, err = client.GetReleaseInfo(context.Background(), &emptypb.Empty{}) + _, err = client.GetReleaseInfo(testutil.TestContext(), &emptypb.Empty{}) require.NoError(t, err, "GetReleaseInfo (public) should succeed without auth in strict mode") assert.Equal(t, int64(1), setup.captured.callCount.Load(), "upstream should be reached") @@ -580,7 +582,7 @@ func TestPublicMethodWorksRegardless(t *testing.T) { defer func() { _ = conn.Close() }() client := kilocenterv1.NewCoreServiceClient(conn) - _, err = client.GetReleaseInfo(context.Background(), &emptypb.Empty{}) + _, err = client.GetReleaseInfo(testutil.TestContext(), &emptypb.Empty{}) require.NoError(t, err, "GetReleaseInfo (public) should succeed without auth in community mode") assert.Equal(t, int64(1), setup.captured.callCount.Load(), "upstream should be reached") diff --git a/KC-Gateway/internal/probe/federation_probe_test.go b/KC-Gateway/internal/probe/federation_probe_test.go index fea915e..066fd46 100644 --- a/KC-Gateway/internal/probe/federation_probe_test.go +++ b/KC-Gateway/internal/probe/federation_probe_test.go @@ -4,6 +4,7 @@ package probe import ( "context" + "crypto/tls" "database/sql" "encoding/binary" "fmt" @@ -78,15 +79,25 @@ func ceDBDSN() string { return "postgres://kilocenter:changeme@localhost:5433/kilocenter_fed_ce?sslmode=disable" } -// dialBSSCI opens a plaintext TCP connection to the given BSSCI address. -// TLS dial is not used in CE test config, which runs plaintext for simplicity. -func dialBSSCIPlain(t *testing.T, addr string) net.Conn { +// dialBSSCI opens a mutual-TLS connection to the given BSSCI address using +// the shared probe client certificate (BSSCI requires mutual TLS). +func dialBSSCI(t *testing.T, addr string) net.Conn { t.Helper() - conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 5 * time.Second}, + "tcp", addr, bssciTLSConfig(t)) require.NoError(t, err, "dial BSSCI at %s", addr) return conn } +// euiBytes renders an EUI as the 8-byte big-endian BYTEA value the messages +// schema stores. +func euiBytes(eui uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, eui) + return b +} + // seedEndpointOn ensures an endpoint with the given EUI exists on the KC-Core at coreAddr. func seedEndpointOn(t *testing.T, coreAddr string, epEUI uint64) { t.Helper() @@ -144,22 +155,59 @@ func openDB(t *testing.T, dsn string) *sql.DB { return db } +// seedBaseStationOn ensures a base station with the given EUI exists on the +// KC-Core at coreAddr; the BSSCI connect handler only accepts registered +// stations. +func seedBaseStationOn(t *testing.T, coreAddr string, bsEUI uint64) { + t.Helper() + conn, err := grpc.NewClient(coreAddr, + grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err, "connect to KC-Core at %s", coreAddr) + defer func() { _ = conn.Close() }() + + euiHex := fmt.Sprintf("%016x", bsEUI) + md := metadata.Pairs( + grpcconst.MetadataKeyInternalTenantID, "1", + grpcconst.MetadataKeyInternalOrgID, "11111111-2222-3333-4444-555555555555", + grpcconst.MetadataKeyInternalUserID, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + ctx := metadata.NewOutgoingContext(context.Background(), md) + + var resp pb.BaseStation + err = conn.Invoke(ctx, "/kilocenter.api.v1.CoreService/CreateBaseStation", + &pb.CreateBaseStationRequest{ + Basestation: &pb.BaseStation{ + BsEui: euiHex, + Name: fmt.Sprintf("probe-fed-bs-%s", euiHex), + }, + }, &resp) + if err != nil { + st, _ := status.FromError(err) + if st.Code() != codes.AlreadyExists { + t.Fatalf("seed base station %s on %s: %v", euiHex, coreAddr, err) + } + } +} + // bssciConnect performs BSSCI version negotiation (con → conRsp → conCmp) using bsEUI. +// The station is registered on the CE core first, and every call starts a new +// session so operation counters never resume from a previous run. // Returns the open connection for subsequent operations. func bssciConnect(t *testing.T, addr string, bsEUI uint64) net.Conn { t.Helper() - conn := dialBSSCIPlain(t, addr) + seedBaseStationOn(t, ceCoreAddr(), bsEUI) + conn := dialBSSCI(t, addr) conReq := mioty.Connect{ BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdConnect, OpId: 0}, Version: mioty.MIOTYProtocolVersion, BsEui: bsEUI, Bidi: true, - SnBsUuid: [16]byte{byte(bsEUI)}, + SnBsUuid: freshSessionUUID(), } writeFrame(t, conn, conReq) - resp := readFrame(t, conn) + resp := awaitFrame(t, conn, mioty.CmdConnectResponse) require.Equal(t, mioty.CmdConnectResponse, resp["command"], "expected conRsp, got %v", resp["command"]) @@ -177,14 +225,14 @@ func bssciSendULData(t *testing.T, conn net.Conn, epEUI uint64, opID int64, user BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdULData, OpId: opID}, EpEui: epEUI, RxTime: time.Now().UnixNano(), - PacketCnt: 1, + PacketCnt: freshPacketCnt(), Snr: 12.0, Rssi: -75.0, UserData: userData, } writeFrame(t, conn, ul) - resp := readFrame(t, conn) + resp := awaitFrame(t, conn, mioty.CmdULDataResponse) require.Equal(t, mioty.CmdULDataResponse, resp["command"], "expected ulDataRsp, got %v", resp["command"]) @@ -198,7 +246,7 @@ func bssciSendULData(t *testing.T, conn net.Conn, epEUI uint64, opID int64, user func bssciSendULDataWithFrame(t *testing.T, conn net.Conn, ul mioty.ULData) { t.Helper() writeFrame(t, conn, ul) - resp := readFrame(t, conn) + resp := awaitFrame(t, conn, mioty.CmdULDataResponse) require.Equal(t, mioty.CmdULDataResponse, resp["command"], "expected ulDataRsp, got %v", resp["command"]) writeFrame(t, conn, mioty.ULDataComplete{ @@ -347,7 +395,7 @@ func TestFederationRelayUnknownULData(t *testing.T) { BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdULData, OpId: 1}, EpEui: epEUI, RxTime: time.Now().UnixNano(), - PacketCnt: 1, + PacketCnt: freshPacketCnt(), Snr: 12.0, Rssi: -75.0, UserData: payload, @@ -384,14 +432,14 @@ func TestFederationRelayUnknownULData(t *testing.T) { require.Equal(t, int64(bsEUI), ceBsEUI) //nolint:gosec // ECE messages: verify the relayed uplink was stored with the synthetic BS EUI and correct payload. - var msgBsEUI int64 + var msgBsEUI []byte var msgPayload []byte err = eceDB.QueryRow( `SELECT m.bs_eui, m.user_data FROM messages m WHERE m.id = $1::uuid`, messageIDStr.String, ).Scan(&msgBsEUI, &msgPayload) require.NoError(t, err, "ECE message must exist for message_id %s", messageIDStr.String) - require.Equal(t, int64(0x7000000000000001), msgBsEUI, "ECE message must carry synthetic BS EUI") //nolint:gosec + require.Equal(t, euiBytes(0x7000000000000001), msgBsEUI, "ECE message must carry synthetic BS EUI") require.Equal(t, payload, msgPayload) // ECE messages: verify uplink flags were forwarded correctly. @@ -420,7 +468,7 @@ func TestFederationRelayUnknownULData(t *testing.T) { var ceCount int err = ceDB.QueryRow( `SELECT COUNT(*) FROM messages WHERE ep_eui = $1 AND created_at >= $2`, - int64(epEUI), runStart, //nolint:gosec + euiBytes(epEUI), runStart, ).Scan(&ceCount) require.NoError(t, err) require.Equal(t, 0, ceCount, "CE should not store messages for endpoints it does not own") @@ -466,7 +514,7 @@ func TestFederationKnownEndpointStaysLocal(t *testing.T) { var count int err := ceDB.QueryRow( `SELECT COUNT(*) FROM messages WHERE ep_eui = $1 AND created_at >= $2`, - int64(epEUI), runStart, //nolint:gosec + euiBytes(epEUI), runStart, ).Scan(&count) return err == nil && count > 0 }) @@ -658,7 +706,7 @@ func TestFederationRevocationStopsRelay(t *testing.T) { // Send an uplink after revocation; it must stay pending and never reach ECE. postRevokePayload := uniquePayload([]byte{0xCA, 0xFE}) - bssciSendULData(t, bssciConn, epEUI, 5, postRevokePayload) + bssciSendULData(t, bssciConn, epEUI, 11, postRevokePayload) time.Sleep(5 * time.Second) var outboxRelayID string diff --git a/KC-Gateway/internal/probe/mioty_loop_probe_test.go b/KC-Gateway/internal/probe/mioty_loop_probe_test.go index 6a5deac..df15450 100644 --- a/KC-Gateway/internal/probe/mioty_loop_probe_test.go +++ b/KC-Gateway/internal/probe/mioty_loop_probe_test.go @@ -37,6 +37,24 @@ func bssciAddr() string { return "localhost:5000" } +// bssciTLSConfig builds the probe's BSSCI TLS client configuration. The BSSCI +// listener requires mutual TLS, so the probe authenticates like a real base +// station: BSSCI_CLIENT_CERT and BSSCI_CLIENT_KEY name a CA-signed client +// certificate pair. +func bssciTLSConfig(t *testing.T) *tls.Config { + t.Helper() + cfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec + certFile := os.Getenv("BSSCI_CLIENT_CERT") + keyFile := os.Getenv("BSSCI_CLIENT_KEY") + if certFile == "" || keyFile == "" { + t.Fatalf("BSSCI_CLIENT_CERT and BSSCI_CLIENT_KEY must point at a CA-signed client certificate pair (the BSSCI listener requires mutual TLS)") + } + pair, err := tls.LoadX509KeyPair(certFile, keyFile) + require.NoError(t, err, "load BSSCI client certificate pair") + cfg.Certificates = []tls.Certificate{pair} + return cfg +} + // probeResult captures structured probe output for automation. type probeResult struct { Probe string `json:"probe"` @@ -149,6 +167,101 @@ func seedTestEndpoint(t *testing.T) { t.Fatalf("failed to seed test endpoint: %v", err) } } + + // The BSSCI connect handler only accepts registered base stations, so the + // probe's station is seeded the same way. + var bsResp pb.BaseStation + err = conn.Invoke(ctx, "/kilocenter.api.v1.CoreService/CreateBaseStation", + &pb.CreateBaseStationRequest{ + Basestation: &pb.BaseStation{ + BsEui: "0000000000000001", + Name: "probe-test-basestation", + }, + }, &bsResp) + if err != nil { + st, _ := status.FromError(err) + if st.Code() != codes.AlreadyExists { + t.Fatalf("failed to seed test base station: %v", err) + } + } +} + +// awaitFrame reads frames until the wanted command arrives, servicing the +// SC-initiated operations a live Service Center interleaves with responses +// (e.g. attach-propagate reconciliation after connect): attPrp/detPrp get +// their response so the SC can complete its handshake, and completes are +// consumed silently. +func awaitFrame(t *testing.T, conn net.Conn, want string) map[string]interface{} { + t.Helper() + for range [16]int{} { + resp := readFrame(t, conn) + cmd, _ := resp["command"].(string) + if cmd == want { + return resp + } + switch cmd { + case mioty.CmdAttachPropagate: + writeFrame(t, conn, mioty.AttachPropagateResponse{ + BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdAttachPropagateResponse, OpId: frameOpID(resp)}, + }) + case mioty.CmdDetachPropagate: + writeFrame(t, conn, mioty.DetachPropagateResponse{ + BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdDetachPropagateResponse, OpId: frameOpID(resp)}, + }) + case mioty.CmdStatus: + writeFrame(t, conn, mioty.StatusResponse{ + BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdStatusResponse, OpId: frameOpID(resp)}, + Code: 0, + Message: "ok", + Time: time.Now().UnixNano(), + DutyCycle: 0, + }) + case mioty.CmdAttachPropagateComplete, mioty.CmdDetachPropagateComplete, + mioty.CmdStatusComplete, mioty.CmdPing: + // Consumed; nothing to answer at this layer. + default: + require.Equal(t, want, cmd, "expected %s, got %v", want, cmd) + } + } + t.Fatalf("no %s frame within 16 reads", want) + return nil +} + +// freshSessionUUID builds a unique BS session UUID so every probe run starts +// a new session instead of resuming the previous run's operation counters. +func freshSessionUUID() [16]byte { + var id [16]byte + binary.BigEndian.PutUint64(id[0:8], uint64(time.Now().UnixNano())) + binary.BigEndian.PutUint64(id[8:16], uint64(os.Getpid())) + return id +} + +// freshPacketCnt returns a per-run monotonic MIOTY packet counter so repeated +// probe runs never collide in the deduplicator. +func freshPacketCnt() uint32 { + return uint32(time.Now().Unix() & 0x7FFFFFFF) //nolint:gosec +} + +// frameOpID extracts the operation ID from a decoded frame. +func frameOpID(resp map[string]interface{}) int64 { + switch v := resp["opId"].(type) { + case int64: + return v + case int8: + return int64(v) + case int16: + return int64(v) + case int32: + return int64(v) + case uint64: + return int64(v) + case int: + return int64(v) + case float64: + return int64(v) + default: + return 0 + } } // TestMIOTYLoopProbe verifies the BS → SC → app path via BSSCI protocol. @@ -166,7 +279,7 @@ func TestMIOTYLoopProbe(t *testing.T) { conn, err := tls.DialWithDialer( &net.Dialer{Timeout: 5 * time.Second}, "tcp", addr, - &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + bssciTLSConfig(t), ) if err != nil { reportResult(t, probeResult{Probe: "bssci_connect", Status: "fail", @@ -185,10 +298,10 @@ func TestMIOTYLoopProbe(t *testing.T) { Version: mioty.MIOTYProtocolVersion, BsEui: 1, Bidi: true, - SnBsUuid: [16]byte{1}, + SnBsUuid: freshSessionUUID(), } writeFrame(t, conn, conReq) - resp := readFrame(t, conn) + resp := awaitFrame(t, conn, mioty.CmdConnectResponse) require.Equal(t, mioty.CmdConnectResponse, resp["command"], "expected conRsp, got %v", resp["command"]) @@ -203,7 +316,9 @@ func TestMIOTYLoopProbe(t *testing.T) { // Compute CMAC signature matching seeded NwkSnKey per MIOTY radio spec §3.7.1.3. // EpEui=2 matches "0000000000000002" seeded in seedTestEndpoint. t.Run("Step3_AttachHandshake", func(t *testing.T) { - var attachCnt uint32 = 1 + // Monotonic across probe runs: replay protection requires the + // attach counter to advance beyond the endpoint's stored value. + attachCnt := uint32(time.Now().Unix() & 0xFFFFFF) sign := computeAttachSignature(2, attachCnt, probeNwkSnKey) att := mioty.Attach{ BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdAttach, OpId: 1}, @@ -216,7 +331,7 @@ func TestMIOTYLoopProbe(t *testing.T) { Sign: sign, } writeFrame(t, conn, att) - resp := readFrame(t, conn) + resp := awaitFrame(t, conn, mioty.CmdAttachResponse) require.Equal(t, mioty.CmdAttachResponse, resp["command"], "expected attRsp, got %v", resp["command"]) @@ -233,13 +348,13 @@ func TestMIOTYLoopProbe(t *testing.T) { BaseMessage: mioty.BaseMessage{CommandType: mioty.CmdULData, OpId: 2}, EpEui: 2, RxTime: time.Now().UnixNano(), - PacketCnt: 1, + PacketCnt: freshPacketCnt(), Snr: 12.0, Rssi: -75.0, UserData: []byte{0xDE, 0xAD}, } writeFrame(t, conn, ul) - resp := readFrame(t, conn) + resp := awaitFrame(t, conn, mioty.CmdULDataResponse) require.Equal(t, mioty.CmdULDataResponse, resp["command"], "expected ulDataRsp, got %v", resp["command"]) diff --git a/KC-Gateway/internal/proxy/metadata_test.go b/KC-Gateway/internal/proxy/metadata_test.go index 1195116..cfa664f 100644 --- a/KC-Gateway/internal/proxy/metadata_test.go +++ b/KC-Gateway/internal/proxy/metadata_test.go @@ -1,13 +1,14 @@ package proxy import ( - "context" "testing" grpcconst "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" pkgcontext "github.com/Kiloiot/kilo-service-center/pkg/context" "github.com/google/uuid" "google.golang.org/grpc/metadata" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) func TestMetadataSanitization(t *testing.T) { @@ -19,7 +20,7 @@ func TestMetadataSanitization(t *testing.T) { grpcconst.MetadataKeyAuthorization, "Bearer spoofed-token", "x-custom-header", "preserved", ) - ctx := metadata.NewIncomingContext(context.Background(), inMD) + ctx := metadata.NewIncomingContext(testutil.TestContext(), inMD) // Simulate interceptor populating context with real identity orgUUID := uuid.MustParse("11111111-2222-3333-4444-555555555555") diff --git a/KC-Gateway/internal/proxy/verification_test.go b/KC-Gateway/internal/proxy/verification_test.go index 1c32526..f7e7fb9 100644 --- a/KC-Gateway/internal/proxy/verification_test.go +++ b/KC-Gateway/internal/proxy/verification_test.go @@ -4,10 +4,16 @@ package proxy import ( "context" + "net" "os" "testing" "time" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc/interceptors" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + grpcproxy "github.com/mwitkow/grpc-proxy/proxy" + "google.golang.org/grpc/metadata" + pb "github.com/Kiloiot/kilo-service-center/KC-Core/api/gen/kilocenter/v1" grpcconst "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/grpc" "github.com/stretchr/testify/assert" @@ -19,12 +25,119 @@ import ( "google.golang.org/protobuf/types/known/emptypb" ) -// gatewayAddr returns the gateway address from env or default. -func gatewayAddr() string { - if addr := os.Getenv("GATEWAY_ADDR"); addr != "" { - return addr +// startMatrixGateway starts a self-contained in-process gateway: the +// production SelectUpstream routing and metadata sanitization in front of +// fake core/identity upstreams (which answer NotFound for every routed call, +// so anything that reaches them is provably not Unimplemented), with the real +// auth interceptor enabled and no credentials supplied. The matrix therefore +// verifies routing, internal-service blocking, and auth gating without any +// external stack. +// matrixCoreStub serves the public CoreService surface the matrix asserts on. +type matrixCoreStub struct { + pb.UnimplementedCoreServiceServer +} + +func (matrixCoreStub) GetReleaseInfo(_ context.Context, _ *emptypb.Empty) (*pb.ReleaseInfo, error) { + return &pb.ReleaseInfo{Version: "matrix-fake"}, nil +} + +// matrixCompatCoreStub serves the KiloCenterService compat methods routed to core. +type matrixCompatCoreStub struct { + pb.UnimplementedKiloCenterServiceServer +} + +func (matrixCompatCoreStub) GetReleaseInfo(_ context.Context, _ *emptypb.Empty) (*pb.ReleaseInfo, error) { + return &pb.ReleaseInfo{Version: "matrix-fake"}, nil +} + +// matrixIdentityStub serves the public IdentityService surface; the other +// public methods answer a domain error (never Unimplemented). +type matrixIdentityStub struct { + pb.UnimplementedIdentityServiceServer +} + +func (matrixIdentityStub) GetAuthSettings(_ context.Context, _ *pb.GetAuthSettingsRequest) (*pb.GetAuthSettingsResponse, error) { + return &pb.GetAuthSettingsResponse{Settings: &pb.AuthSettings{}}, nil +} + +func (matrixIdentityStub) Login(_ context.Context, _ *pb.LoginRequest) (*pb.LoginResponse, error) { + return nil, status.Error(codes.InvalidArgument, "matrix fake identity") +} + +func (matrixIdentityStub) RefreshTokens(_ context.Context, _ *pb.RefreshTokensRequest) (*pb.RefreshTokensResponse, error) { + return nil, status.Error(codes.InvalidArgument, "matrix fake identity") +} + +func (matrixIdentityStub) ExchangeOIDC(_ context.Context, _ *pb.ExchangeOIDCRequest) (*pb.LoginResponse, error) { + return nil, status.Error(codes.InvalidArgument, "matrix fake identity") +} + +func (matrixIdentityStub) ExchangeOAuth2(_ context.Context, _ *pb.ExchangeOAuth2Request) (*pb.LoginResponse, error) { + return nil, status.Error(codes.InvalidArgument, "matrix fake identity") +} + +func (matrixIdentityStub) RegisterAccount(_ context.Context, _ *pb.RegisterAccountRequest) (*pb.LoginResponse, error) { + return nil, status.Error(codes.InvalidArgument, "matrix fake identity") +} + +func startMatrixGateway(t *testing.T) string { + t.Helper() + logger.Initialize("error", "text") + l := logger.Get() + + fakeUpstream := func(register func(*grpc.Server)) net.Listener { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + srv := grpc.NewServer(grpc.UnknownServiceHandler(func(_ interface{}, _ grpc.ServerStream) error { + return status.Error(codes.NotFound, "matrix fake upstream") + })) + register(srv) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.GracefulStop) + return lis } - return "localhost:9090" + + coreLis := fakeUpstream(func(srv *grpc.Server) { + pb.RegisterCoreServiceServer(srv, &matrixCoreStub{}) + pb.RegisterKiloCenterServiceServer(srv, &matrixCompatCoreStub{}) + }) + identityLis := fakeUpstream(func(srv *grpc.Server) { + pb.RegisterIdentityServiceServer(srv, &matrixIdentityStub{}) + }) + + dial := func(addr string) *grpc.ClientConn { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn + } + coreConn := dial(coreLis.Addr().String()) + identityConn := dial(identityLis.Addr().String()) + + authInterceptor, err := interceptors.NewAuthInterceptor(interceptors.AuthConfig{Enabled: true}) + require.NoError(t, err) + _ = l + + director := func(ctx context.Context, fullMethod string) (context.Context, grpc.ClientConnInterface, error) { + upstream, selErr := SelectUpstream(fullMethod, coreConn, identityConn) + if selErr != nil { + return nil, nil, selErr + } + outMD := SanitizeAndInject(ctx) + return metadata.NewOutgoingContext(ctx, outMD), upstream, nil + } + + gatewayServer := grpc.NewServer( + grpc.ChainUnaryInterceptor(authInterceptor.UnaryInterceptor()), + grpc.ChainStreamInterceptor(authInterceptor.StreamInterceptor()), + grpc.UnknownServiceHandler(grpcproxy.TransparentHandler(director)), + ) + gatewayLis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { _ = gatewayServer.Serve(gatewayLis) }() + t.Cleanup(gatewayServer.GracefulStop) + + return gatewayLis.Addr().String() } // publicMethods that should return OK or domain error (NOT Unimplemented) via gateway. @@ -102,7 +215,21 @@ var identityServiceUnaryMethods = []string{ } func TestVerificationMatrix(t *testing.T) { - conn, err := grpc.NewClient(gatewayAddr(), + runVerificationMatrix(t, startMatrixGateway(t)) +} + +// TestVerificationMatrixExternal runs the same matrix against a running +// gateway (full-stack smoke); it skips unless GATEWAY_ADDR is set. +func TestVerificationMatrixExternal(t *testing.T) { + addr := os.Getenv("GATEWAY_ADDR") + if addr == "" { + t.Skip("GATEWAY_ADDR not set; external gateway matrix skipped (in-process matrix covers routing)") + } + runVerificationMatrix(t, addr) +} + +func runVerificationMatrix(t *testing.T, addr string) { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) require.NoError(t, err) defer func() { _ = conn.Close() }() diff --git a/KC-Gateway/internal/resilience/rate_limiter_test.go b/KC-Gateway/internal/resilience/rate_limiter_test.go index 9c94463..5696848 100644 --- a/KC-Gateway/internal/resilience/rate_limiter_test.go +++ b/KC-Gateway/internal/resilience/rate_limiter_test.go @@ -13,6 +13,8 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) type mockServerStream struct { @@ -25,7 +27,7 @@ func (m *mockServerStream) Context() context.Context { } func newPeerContext(ip string) context.Context { - return peer.NewContext(context.Background(), &peer.Peer{ + return peer.NewContext(testutil.TestContext(), &peer.Peer{ Addr: &net.TCPAddr{IP: net.ParseIP(ip), Port: 12345}, }) } diff --git a/KC-Identity/internal/grpc/org_resolution_test.go b/KC-Identity/internal/grpc/org_resolution_test.go index 8b616ee..9c92fcc 100644 --- a/KC-Identity/internal/grpc/org_resolution_test.go +++ b/KC-Identity/internal/grpc/org_resolution_test.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockMembershipLookup implements MembershipLookup for testing. @@ -29,7 +31,7 @@ func TestGetUserMembership_ValidRequest(t *testing.T) { mock := &mockMembershipLookup{role: "admin", isActive: true} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - resp, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + resp, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "fe7fe002-6880-4ea6-84ed-a69911dbdf8c", UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) @@ -48,7 +50,7 @@ func TestGetUserMembership_InactiveUser(t *testing.T) { mock := &mockMembershipLookup{role: "viewer", isActive: false} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - resp, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + resp, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "fe7fe002-6880-4ea6-84ed-a69911dbdf8c", UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) @@ -67,7 +69,7 @@ func TestGetUserMembership_EmptyOrgID(t *testing.T) { mock := &mockMembershipLookup{role: "admin", isActive: true} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - _, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + _, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "", UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) @@ -93,7 +95,7 @@ func TestGetUserMembership_EmptyUserID(t *testing.T) { mock := &mockMembershipLookup{role: "admin", isActive: true} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - _, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + _, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "fe7fe002-6880-4ea6-84ed-a69911dbdf8c", UserId: "", }) @@ -119,7 +121,7 @@ func TestGetUserMembership_InvalidOrgIDFormat(t *testing.T) { mock := &mockMembershipLookup{role: "admin", isActive: true} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - _, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + _, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "not-a-uuid", UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) @@ -145,7 +147,7 @@ func TestGetUserMembership_InvalidUserIDFormat(t *testing.T) { mock := &mockMembershipLookup{role: "admin", isActive: true} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - _, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + _, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "fe7fe002-6880-4ea6-84ed-a69911dbdf8c", UserId: "not-a-uuid", }) @@ -171,7 +173,7 @@ func TestGetUserMembership_NotFound(t *testing.T) { mock := &mockMembershipLookup{err: storage.ErrNotFound} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - _, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + _, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "fe7fe002-6880-4ea6-84ed-a69911dbdf8c", UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) @@ -197,7 +199,7 @@ func TestGetUserMembership_RepoFailure(t *testing.T) { mock := &mockMembershipLookup{err: fmt.Errorf("database connection lost")} svc := NewIdentityInternalService(nil, nil, mock, nil, nil) - _, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + _, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "fe7fe002-6880-4ea6-84ed-a69911dbdf8c", UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) @@ -222,7 +224,7 @@ func TestGetUserMembership_RepoFailure(t *testing.T) { func TestGetUserMembership_NilMembershipService(t *testing.T) { svc := NewIdentityInternalService(nil, nil, nil, nil, nil) - _, err := svc.GetUserMembership(context.Background(), &pb.GetUserMembershipRequest{ + _, err := svc.GetUserMembership(testutil.TestContext(), &pb.GetUserMembershipRequest{ OrgId: "fe7fe002-6880-4ea6-84ed-a69911dbdf8c", UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) @@ -258,7 +260,7 @@ func TestCheckServerAdmin_AdminUser(t *testing.T) { mock := &mockUserLookup{user: &models.User{IsAdmin: true}} svc := NewIdentityInternalService(nil, nil, nil, mock, nil) - resp, err := svc.CheckServerAdmin(context.Background(), &pb.CheckServerAdminRequest{ + resp, err := svc.CheckServerAdmin(testutil.TestContext(), &pb.CheckServerAdminRequest{ UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) if err != nil { @@ -273,7 +275,7 @@ func TestCheckServerAdmin_NonAdminUser(t *testing.T) { mock := &mockUserLookup{user: &models.User{IsAdmin: false}} svc := NewIdentityInternalService(nil, nil, nil, mock, nil) - resp, err := svc.CheckServerAdmin(context.Background(), &pb.CheckServerAdminRequest{ + resp, err := svc.CheckServerAdmin(testutil.TestContext(), &pb.CheckServerAdminRequest{ UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) if err != nil { @@ -287,7 +289,7 @@ func TestCheckServerAdmin_NonAdminUser(t *testing.T) { func TestCheckServerAdmin_InvalidUUID(t *testing.T) { svc := NewIdentityInternalService(nil, nil, nil, nil, nil) - _, err := svc.CheckServerAdmin(context.Background(), &pb.CheckServerAdminRequest{ + _, err := svc.CheckServerAdmin(testutil.TestContext(), &pb.CheckServerAdminRequest{ UserId: "not-a-uuid", }) if err == nil { @@ -303,7 +305,7 @@ func TestCheckServerAdmin_InvalidUUID(t *testing.T) { func TestCheckServerAdmin_EmptyUserID(t *testing.T) { svc := NewIdentityInternalService(nil, nil, nil, nil, nil) - _, err := svc.CheckServerAdmin(context.Background(), &pb.CheckServerAdminRequest{ + _, err := svc.CheckServerAdmin(testutil.TestContext(), &pb.CheckServerAdminRequest{ UserId: "", }) if err == nil { @@ -315,7 +317,7 @@ func TestCheckServerAdmin_UserNotFound(t *testing.T) { mock := &mockUserLookup{err: storage.ErrNotFound} svc := NewIdentityInternalService(nil, nil, nil, mock, nil) - _, err := svc.CheckServerAdmin(context.Background(), &pb.CheckServerAdminRequest{ + _, err := svc.CheckServerAdmin(testutil.TestContext(), &pb.CheckServerAdminRequest{ UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) if err == nil { @@ -330,7 +332,7 @@ func TestCheckServerAdmin_UserNotFound(t *testing.T) { func TestCheckServerAdmin_NilUserSvc(t *testing.T) { svc := NewIdentityInternalService(nil, nil, nil, nil, nil) - _, err := svc.CheckServerAdmin(context.Background(), &pb.CheckServerAdminRequest{ + _, err := svc.CheckServerAdmin(testutil.TestContext(), &pb.CheckServerAdminRequest{ UserId: "be8a02c9-1470-4314-8a86-48712761aca9", }) if err == nil { @@ -357,7 +359,7 @@ func TestRecordPlatformEvent_WithEventWriter(t *testing.T) { writer := &mockEventWriter{} svc := NewIdentityInternalService(nil, nil, nil, nil, writer) - resp, err := svc.RecordPlatformEvent(context.Background(), &pb.RecordPlatformEventRequest{ + resp, err := svc.RecordPlatformEvent(testutil.TestContext(), &pb.RecordPlatformEventRequest{ TenantId: 1, EventType: "service.started", Category: "system", @@ -399,7 +401,7 @@ func TestRecordPlatformEvent_WithEventWriter(t *testing.T) { func TestRecordPlatformEvent_NilEventWriter(t *testing.T) { svc := NewIdentityInternalService(nil, nil, nil, nil, nil) - resp, err := svc.RecordPlatformEvent(context.Background(), &pb.RecordPlatformEventRequest{ + resp, err := svc.RecordPlatformEvent(testutil.TestContext(), &pb.RecordPlatformEventRequest{ TenantId: 1, EventType: "service.started", Category: "system", diff --git a/KC-MQTT/pkg/mqtt/client_test.go b/KC-MQTT/pkg/mqtt/client_test.go index 4e87b89..91104d5 100644 --- a/KC-MQTT/pkg/mqtt/client_test.go +++ b/KC-MQTT/pkg/mqtt/client_test.go @@ -6,6 +6,8 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/config" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // MockLogger implements the Logger interface for testing @@ -120,7 +122,7 @@ func TestPublishNotConnected(t *testing.T) { } // Attempt publish without connecting (use QoS 0, not retained) - err = client.Publish(context.Background(), "test/topic", 0, false, []byte("test payload")) + err = client.Publish(testutil.TestContext(), "test/topic", 0, false, []byte("test payload")) if err == nil { t.Error("Expected error when publishing without connection") } @@ -137,7 +139,7 @@ func TestSubscribeNotConnected(t *testing.T) { } // Attempt subscribe without connecting (use QoS 0, nil handler is OK for test) - err = client.Subscribe(context.Background(), "test/topic", 0, nil) + err = client.Subscribe(testutil.TestContext(), "test/topic", 0, nil) if err == nil { t.Error("Expected error when subscribing without connection") } @@ -156,7 +158,7 @@ func TestPublishNotConnected_WithHandler(t *testing.T) { // Attempt publish without connecting (real payload, not nil) payload := []byte("test message payload") - err = client.Publish(context.Background(), "mioty/uplink/test", 0, false, payload) + err = client.Publish(testutil.TestContext(), "mioty/uplink/test", 0, false, payload) // Must fail with "not connected" error (proves connection guard is triggered) if err == nil { @@ -184,7 +186,7 @@ func TestSubscribeNotConnected_WithHandler(t *testing.T) { handlerCalled = true } - err = client.Subscribe(context.Background(), "mioty/uplink/#", 0, handler) + err = client.Subscribe(testutil.TestContext(), "mioty/uplink/#", 0, handler) // Must fail with "not connected" error (proves connection guard is triggered, not handler validation) if err == nil { diff --git a/KC-MQTT/pkg/mqtt/command_handler_test.go b/KC-MQTT/pkg/mqtt/command_handler_test.go index d61fc5c..496734c 100644 --- a/KC-MQTT/pkg/mqtt/command_handler_test.go +++ b/KC-MQTT/pkg/mqtt/command_handler_test.go @@ -13,6 +13,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockDownlinkEnqueuer records EnqueueFromMQTT calls. @@ -116,7 +118,7 @@ func (m *mockCommandPublisher) simulateMessage(topic string, payload []byte) { var handler MessageHandler ctx := m.subCtx if ctx == nil { - ctx = context.Background() + ctx = testutil.TestContext() } for subTopic, h := range m.subscribers { if topicMatchesWildcard(subTopic, topic) { @@ -172,7 +174,7 @@ func TestCommandHandler_ValidTopicParsesCorrectly(t *testing.T) { handler := NewCommandHandler(pub, enqueuer, lookup, log, "mioty") // Start in background, then simulate message - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(testutil.TestContext()) defer cancel() go handler.Start(ctx) @@ -208,7 +210,7 @@ func TestCommandHandler_InvalidTopicTooFewSegments(t *testing.T) { } // Directly call handleMessage with a bad topic - handler.handleMessage(context.Background(), "mioty/bad", []byte("{}")) + handler.handleMessage(testutil.TestContext(), "mioty/bad", []byte("{}")) assert.Equal(t, 0, enqueuer.callCount()) } @@ -225,7 +227,7 @@ func TestCommandHandler_InvalidOrgUUID(t *testing.T) { prefix: "mioty", } - handler.handleMessage(context.Background(), "mioty/not-a-uuid/device/70b3d59cd00009e6/command/down", []byte("{}")) + handler.handleMessage(testutil.TestContext(), "mioty/not-a-uuid/device/70b3d59cd00009e6/command/down", []byte("{}")) assert.Equal(t, 0, enqueuer.callCount()) } @@ -244,7 +246,7 @@ func TestCommandHandler_InvalidHexEUI(t *testing.T) { } topic := "mioty/" + orgUUID.String() + "/device/not-hex/command/down" - handler.handleMessage(context.Background(), topic, buildCommandPayload(base64.StdEncoding.EncodeToString([]byte("test")), false)) + handler.handleMessage(testutil.TestContext(), topic, buildCommandPayload(base64.StdEncoding.EncodeToString([]byte("test")), false)) assert.Equal(t, 0, enqueuer.callCount()) } @@ -263,7 +265,7 @@ func TestCommandHandler_EmptyPayloadRejected(t *testing.T) { } topic := "mioty/" + orgUUID.String() + "/device/70b3d59cd00009e6/command/down" - handler.handleMessage(context.Background(), topic, []byte{}) + handler.handleMessage(testutil.TestContext(), topic, []byte{}) assert.Equal(t, 0, enqueuer.callCount()) } @@ -283,7 +285,7 @@ func TestCommandHandler_OversizedRawPayloadRejected(t *testing.T) { topic := "mioty/" + orgUUID.String() + "/device/70b3d59cd00009e6/command/down" oversized := make([]byte, config.MaxMessageSize+1) - handler.handleMessage(context.Background(), topic, oversized) + handler.handleMessage(testutil.TestContext(), topic, oversized) assert.Equal(t, 0, enqueuer.callCount()) } @@ -306,7 +308,7 @@ func TestCommandHandler_DecodedEmptyPayloadRejected(t *testing.T) { topic := "mioty/" + orgUUID.String() + "/device/70b3d59cd00009e6/command/down" // Base64 of empty string cmdPayload := buildCommandPayload("", false) - handler.handleMessage(context.Background(), topic, cmdPayload) + handler.handleMessage(testutil.TestContext(), topic, cmdPayload) assert.Equal(t, 0, enqueuer.callCount()) } @@ -331,7 +333,7 @@ func TestCommandHandler_DecodedOversizedPayloadRejected(t *testing.T) { bigData := make([]byte, config.MaxPayloadSize+1) encoded := base64.StdEncoding.EncodeToString(bigData) cmdPayload := buildCommandPayload(encoded, false) - handler.handleMessage(context.Background(), topic, cmdPayload) + handler.handleMessage(testutil.TestContext(), topic, cmdPayload) assert.Equal(t, 0, enqueuer.callCount()) } @@ -355,13 +357,13 @@ func TestCommandHandler_ConfirmedFlagPropagated(t *testing.T) { // Test confirmed=false cmdPayload := buildCommandPayload(base64.StdEncoding.EncodeToString([]byte("test")), false) - handler.handleMessage(context.Background(), topic, cmdPayload) + handler.handleMessage(testutil.TestContext(), topic, cmdPayload) require.Equal(t, 1, enqueuer.callCount()) assert.False(t, enqueuer.lastCall().Confirmed) // Test confirmed=true cmdPayload = buildCommandPayload(base64.StdEncoding.EncodeToString([]byte("test2")), true) - handler.handleMessage(context.Background(), topic, cmdPayload) + handler.handleMessage(testutil.TestContext(), topic, cmdPayload) require.Equal(t, 2, enqueuer.callCount()) assert.True(t, enqueuer.lastCall().Confirmed) } @@ -381,7 +383,7 @@ func TestCommandHandler_NilOrgUUIDRejected(t *testing.T) { // uuid.Nil should be rejected topic := "mioty/" + uuid.Nil.String() + "/device/70b3d59cd00009e6/command/down" cmdPayload := buildCommandPayload(base64.StdEncoding.EncodeToString([]byte("test")), false) - handler.handleMessage(context.Background(), topic, cmdPayload) + handler.handleMessage(testutil.TestContext(), topic, cmdPayload) assert.Equal(t, 0, enqueuer.callCount()) } @@ -417,7 +419,7 @@ func TestCommandHandler_NilDependencies_EarlyReturn(t *testing.T) { t.Parallel() log := &recordingLogger{} h := NewCommandHandler(nil, &mockDownlinkEnqueuer{}, &mockTenantLookup{}, log, "mioty") - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(testutil.TestContext()) cancel() assert.NotPanics(t, func() { h.Start(ctx) }) assert.Equal(t, ErrCommandHandlerMissingDeps, log.lastError()) @@ -427,7 +429,7 @@ func TestCommandHandler_NilDependencies_EarlyReturn(t *testing.T) { t.Parallel() log := &recordingLogger{} h := NewCommandHandler(newMockCommandPublisher(), nil, &mockTenantLookup{}, log, "mioty") - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(testutil.TestContext()) cancel() assert.NotPanics(t, func() { h.Start(ctx) }) assert.Equal(t, ErrCommandHandlerMissingDeps, log.lastError()) @@ -437,7 +439,7 @@ func TestCommandHandler_NilDependencies_EarlyReturn(t *testing.T) { t.Parallel() log := &recordingLogger{} h := NewCommandHandler(newMockCommandPublisher(), &mockDownlinkEnqueuer{}, nil, log, "mioty") - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(testutil.TestContext()) cancel() assert.NotPanics(t, func() { h.Start(ctx) }) assert.Equal(t, ErrCommandHandlerMissingDeps, log.lastError()) @@ -446,7 +448,7 @@ func TestCommandHandler_NilDependencies_EarlyReturn(t *testing.T) { t.Run("nil logger silent return", func(t *testing.T) { t.Parallel() h := NewCommandHandler(newMockCommandPublisher(), &mockDownlinkEnqueuer{}, &mockTenantLookup{}, nil, "mioty") - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(testutil.TestContext()) cancel() assert.NotPanics(t, func() { h.Start(ctx) }) }) @@ -466,7 +468,7 @@ func TestCommandHandler_ContextPropagation(t *testing.T) { // Inject a marker value into the Start context type ctxKey struct{} - startCtx, cancel := context.WithCancel(context.WithValue(context.Background(), ctxKey{}, "test-marker")) + startCtx, cancel := context.WithCancel(context.WithValue(testutil.TestContext(), ctxKey{}, "test-marker")) defer cancel() go handler.Start(startCtx) diff --git a/KC-MQTT/pkg/mqtt/publisher_test.go b/KC-MQTT/pkg/mqtt/publisher_test.go index 2efce8f..56c8f8e 100644 --- a/KC-MQTT/pkg/mqtt/publisher_test.go +++ b/KC-MQTT/pkg/mqtt/publisher_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" ) // mockPublisher records Publish calls for verification. @@ -49,7 +51,7 @@ func TestPublishDeviceEvent_RejectsEmptyOrgUUID(t *testing.T) { t.Parallel() pub := &TopicPublisher{client: &mockPublisher{}, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "", "0123456789abcdef", DeviceEventUp, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "", "0123456789abcdef", DeviceEventUp, []byte("test")) require.Error(t, err) assert.Contains(t, err.Error(), ErrDeviceEventEmptyOrgUUID) @@ -59,7 +61,7 @@ func TestPublishDeviceEvent_RejectsEmptyEUIHex(t *testing.T) { t.Parallel() pub := &TopicPublisher{client: &mockPublisher{}, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "org-uuid", "", DeviceEventUp, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "org-uuid", "", DeviceEventUp, []byte("test")) require.Error(t, err) assert.Contains(t, err.Error(), ErrDeviceEventEmptyEUIHex) @@ -70,7 +72,7 @@ func TestPublishDeviceEvent_UplinkUsesUplinkQoS(t *testing.T) { mock := &mockPublisher{} pub := &TopicPublisher{client: mock, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "org-uuid", "0123456789abcdef", DeviceEventUp, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "org-uuid", "0123456789abcdef", DeviceEventUp, []byte("test")) require.NoError(t, err) call := mock.lastCall() @@ -83,7 +85,7 @@ func TestPublishDeviceEvent_AttachUsesEventsQoS(t *testing.T) { mock := &mockPublisher{} pub := &TopicPublisher{client: mock, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "org-uuid", "0123456789abcdef", DeviceEventAttach, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "org-uuid", "0123456789abcdef", DeviceEventAttach, []byte("test")) require.NoError(t, err) call := mock.lastCall() @@ -96,7 +98,7 @@ func TestPublishDeviceEvent_DetachUsesEventsQoS(t *testing.T) { mock := &mockPublisher{} pub := &TopicPublisher{client: mock, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "org-uuid", "0123456789abcdef", DeviceEventDetach, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "org-uuid", "0123456789abcdef", DeviceEventDetach, []byte("test")) require.NoError(t, err) call := mock.lastCall() @@ -109,7 +111,7 @@ func TestPublishDeviceEvent_DownlinkResultUsesEventsQoS(t *testing.T) { mock := &mockPublisher{} pub := &TopicPublisher{client: mock, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "org-uuid", "0123456789abcdef", DeviceEventDownlinkResult, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "org-uuid", "0123456789abcdef", DeviceEventDownlinkResult, []byte("test")) require.NoError(t, err) call := mock.lastCall() @@ -122,7 +124,7 @@ func TestPublishDeviceEvent_PropagatesPublishError(t *testing.T) { mock := &mockPublisher{returnErr: fmt.Errorf("broker unavailable")} pub := &TopicPublisher{client: mock, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "org-uuid", "0123456789abcdef", DeviceEventUp, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "org-uuid", "0123456789abcdef", DeviceEventUp, []byte("test")) require.Error(t, err) assert.Contains(t, err.Error(), "broker unavailable") @@ -133,7 +135,7 @@ func TestPublishDeviceEvent_NotRetained(t *testing.T) { mock := &mockPublisher{} pub := &TopicPublisher{client: mock, prefix: "mioty"} - err := pub.PublishDeviceEvent(context.Background(), "org-uuid", "0123456789abcdef", DeviceEventUp, []byte("test")) + err := pub.PublishDeviceEvent(testutil.TestContext(), "org-uuid", "0123456789abcdef", DeviceEventUp, []byte("test")) require.NoError(t, err) call := mock.lastCall() diff --git a/config/config.docker.yaml b/config/config.docker.yaml index ca0d23b..5ea1b1b 100644 --- a/config/config.docker.yaml +++ b/config/config.docker.yaml @@ -40,6 +40,10 @@ protocol: dlrx_cleanup_interval: 60 duplicate_window: 300 message_encoding: "msgpack" + bsci_certificate_poll_interval: "10s" + # Service Center EUI. Empty defers to KILOCENTER_PROTOCOL_SC_EUI, the legacy + # SERVICE_CENTER_EUI environment variable, or the built-in default. + sc_eui: "" certificates: certgen_path: "/usr/local/bin/certgen" diff --git a/helm/kilocenter/README.md b/helm/kilocenter/README.md index b0a496b..99e22a4 100644 --- a/helm/kilocenter/README.md +++ b/helm/kilocenter/README.md @@ -83,6 +83,13 @@ helm install kilocenter ./helm/kilocenter -f my-values.yaml | `kcCore.config.grpc.port` | Internal gRPC port | `50051` | | `kcCore.config.health.port` | Health endpoint port | `8086` | | `kcCore.config.messageRetentionDays` | Message retention in days | `90` | +| `kcCore.config.protocol.connectionEstablishmentTimeout` | BSSCI connection establishment timeout in ms | `30000` | +| `kcCore.config.protocol.statusRequestInterval` | BSSCI status poll interval in seconds | `30` | +| `kcCore.config.protocol.statusRequestInitialDelay` | Delay before the first status poll in seconds | `5` | +| `kcCore.config.protocol.dlrxQueryTimeout` | DL RX status query timeout in seconds | `300` | +| `kcCore.config.protocol.dlrxCleanupInterval` | DL RX status query cleanup interval in seconds | `60` | +| `kcCore.config.protocol.bsciCertificatePollInterval` | Base station certificate poll interval | `"10s"` | +| `kcCore.config.protocol.scEui` | Service Center EUI; empty defers to env vars or the built-in default | `""` | ### KC-Gateway diff --git a/helm/kilocenter/templates/kc-core-configmap.yaml b/helm/kilocenter/templates/kc-core-configmap.yaml index 598217f..757c58c 100644 --- a/helm/kilocenter/templates/kc-core-configmap.yaml +++ b/helm/kilocenter/templates/kc-core-configmap.yaml @@ -35,6 +35,15 @@ data: max_retransmissions: {{ .Values.kcCore.config.protocol.maxRetransmissions }} ack_timeout: {{ .Values.kcCore.config.protocol.ackTimeout }} duplicate_window: {{ .Values.kcCore.config.protocol.duplicateWindow }} + connection_establishment_timeout: {{ .Values.kcCore.config.protocol.connectionEstablishmentTimeout }} + status_request_interval: {{ .Values.kcCore.config.protocol.statusRequestInterval }} + status_request_initial_delay: {{ .Values.kcCore.config.protocol.statusRequestInitialDelay }} + dlrx_query_timeout: {{ .Values.kcCore.config.protocol.dlrxQueryTimeout }} + dlrx_cleanup_interval: {{ .Values.kcCore.config.protocol.dlrxCleanupInterval }} + bsci_certificate_poll_interval: {{ .Values.kcCore.config.protocol.bsciCertificatePollInterval | quote }} + {{- with .Values.kcCore.config.protocol.scEui }} + sc_eui: {{ . | quote }} + {{- end }} message_encoding: "msgpack" certificates: diff --git a/helm/kilocenter/values.yaml b/helm/kilocenter/values.yaml index ec2d180..5e5db68 100644 --- a/helm/kilocenter/values.yaml +++ b/helm/kilocenter/values.yaml @@ -74,6 +74,15 @@ kcCore: maxRetransmissions: 3 ackTimeout: 5000 duplicateWindow: 300 + connectionEstablishmentTimeout: 30000 + statusRequestInterval: 30 + statusRequestInitialDelay: 5 + dlrxQueryTimeout: 300 + dlrxCleanupInterval: 60 + bsciCertificatePollInterval: "10s" + # Service Center EUI. Empty keeps the sc_eui key out of the rendered + # config so environment variables and the built-in default apply. + scEui: "" # --------------------------------------------------------------------------- # KC-Gateway — external gRPC-web ingress, proxies to KC-Core From 8ea4a05d9e4df2e1cd35c1e92002e2d33c668bf1 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:01:57 +0200 Subject: [PATCH 08/25] refactor(core): narrow storage contracts and SCACI context threading CHIRP-3545. The protocol servers stop consuming the shared storage facade and instead depend on narrow, consumer-owned contracts satisfied structurally by the repositories, so errors.Is keeps working without adapters. StatusService owns all pending-operation persistence, session counters unify on UpdateOperationIDs, and the attach transactions move to a dedicated persistence collaborator. Both protocol servers are constructor-injected, with ConfigureRuntime supplying the circular dependencies before Start. SCACI threads context through the subsystem, taking its SOLID exemptions to zero. MQTT events are no longer published for an unresolved organization. --- KC-Core/cmd/kilocenter/builders/protocol.go | 205 +++--- .../internal/grpc/core_service_dldata_test.go | 13 + KC-Core/internal/grpc/core_service_test.go | 13 + .../services/bssci/connection_service.go | 17 + .../services/bssci/downlink_dispatcher.go | 46 +- .../endpoint_attachment_persistence_test.go | 160 +++++ .../bssci/endpoint_attachment_service.go | 183 +++++ .../services/bssci/session_service.go | 12 +- .../internal/services/bssci/status_service.go | 57 ++ .../services/scaci/certificate_verifier.go | 15 +- .../scaci/certificate_verifier_test.go | 20 +- .../services/scaci/endpoint_service.go | 4 +- .../services/scaci/handshake_service.go | 20 +- .../services/scaci/handshake_service_test.go | 12 +- .../services/scaci/session_persistence.go | 67 +- .../scaci/session_persistence_test.go | 18 +- .../services/scaci/session_validator.go | 2 +- .../services/scaci/session_validator_test.go | 14 +- .../pkg/bssci/attach_detach_profile_test.go | 4 +- .../attach_propagate_integration_test.go | 6 +- .../bssci/attach_replay_protection_test.go | 8 +- KC-Core/pkg/bssci/connect_failure_test.go | 6 + KC-Core/pkg/bssci/connect_handler_test.go | 6 + KC-Core/pkg/bssci/contracts.go | 149 +++++ KC-Core/pkg/bssci/detach_integration_test.go | 12 +- .../bssci/detach_propagate_complete_test.go | 6 +- KC-Core/pkg/bssci/disposition.go | 44 -- KC-Core/pkg/bssci/dl_rx_status_test.go | 15 +- KC-Core/pkg/bssci/downlink_handlers.go | 96 ++- .../bssci/mandatory_field_validation_test.go | 6 +- KC-Core/pkg/bssci/mqtt_publish_test.go | 49 +- KC-Core/pkg/bssci/pending_op_leak_test.go | 2 +- KC-Core/pkg/bssci/server.go | 627 +++++++----------- KC-Core/pkg/bssci/status_handlers.go | 4 +- KC-Core/pkg/bssci/tenant_context_test.go | 2 +- KC-Core/pkg/bssci/test_helpers_test.go | 297 ++++++++- KC-Core/pkg/bssci/version_interop_test.go | 6 + KC-Core/pkg/bssci/vm_handlers_test.go | 1 - KC-Core/pkg/endpoint/state.go | 11 +- KC-Core/pkg/scaci/contracts.go | 65 +- KC-Core/pkg/scaci/error_recorder.go | 4 +- KC-Core/pkg/scaci/handler_connect.go | 24 +- KC-Core/pkg/scaci/handler_connect_test.go | 35 +- KC-Core/pkg/scaci/handler_operations.go | 2 +- KC-Core/pkg/scaci/handler_operations_test.go | 8 +- KC-Core/pkg/scaci/log_messages.go | 10 + KC-Core/pkg/scaci/mocks_test.go | 20 +- KC-Core/pkg/scaci/server.go | 152 +++-- KC-Core/pkg/scaci/server_replay_test.go | 2 +- KC-Core/pkg/scaci/server_test.go | 18 +- 50 files changed, 1706 insertions(+), 869 deletions(-) create mode 100644 KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go delete mode 100644 KC-Core/pkg/bssci/disposition.go diff --git a/KC-Core/cmd/kilocenter/builders/protocol.go b/KC-Core/cmd/kilocenter/builders/protocol.go index 77d6568..8bc8dc6 100644 --- a/KC-Core/cmd/kilocenter/builders/protocol.go +++ b/KC-Core/cmd/kilocenter/builders/protocol.go @@ -13,6 +13,7 @@ import ( scaciservices "github.com/Kiloiot/kilo-service-center/KC-Core/internal/services/scaci" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" pkgconfig "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/config" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/crypto" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/scaci" @@ -125,59 +126,20 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol FallbackTenantID: infra.TenantID, } - // Two-step initialization: server first, then propagation service (BSSCI §5.8-5.8.3) - bssciServer, err := bssci.NewServer( - bssciConfig, - infra.LoggerIface, - bssciInfra.ConnectionMgr, - bssciInfra.Storage, - bssciInfra.SystemEventStore, - bssciInfra.BasestationRepo, - bssciInfra.EndpointRepo, - infra.TenantID, - bssciSvcBundle.SessionSvc, - bssciSvcBundle.VersionNegotiator, - bssciSvcBundle.DownlinkSvc, - bssciSvcBundle.StatusSvc, - bssciSvcBundle.ConnectionSvc, - bssciSvcBundle.Broadcaster, - bssciSvcBundle.QueueSerializer, - bssciSvcBundle.AuditLogger, - bssciSvcBundle.TenantResolver, - bssciInfra.OrgResolver, - bssciInfra.FallbackTenantID, - ) - if err != nil { - return nil, fmt.Errorf("failed to create BSSCI server: %w (StatusService is mandatory for pending operation tracking)", err) + // Root-owned shared infrastructure: the message deduplicator (shared by + // the ingest pipeline) and the network key encryptor with its + // warn-and-continue fallback. The explicit nil-interface guard avoids + // wrapping a nil concrete pointer. + deduplicator := bssci.NewMessageDeduplicator(bssciConfig.EffectiveDuplicateWindow()) + cleanups = append(cleanups, deduplicator.Stop) + + var keyProtector bssci.NetworkKeyProtector + if ke, keErr := crypto.NewKeyEncryptor(); keErr != nil { + log.Warn("Failed to initialize key encryptor", "error", keErr) + } else if ke != nil { + keyProtector = ke } - // Certificate identity: the CE composite resolver handles EUI CNs against - // the registered stations and delegates org- CNs to the deployment's - // org resolver; the directory backs connect-time fingerprint enforcement - bssciServer.SetCertificateIdentityResolver(bssciservices.NewCertificateIdentityResolver( - bssciInfra.BasestationRepo, - bssciInfra.OrgResolver, - infra.LoggerIface, - )) - bssciServer.SetBaseStationDirectory(bssciservices.NewRegisteredBaseStationDirectory(bssciInfra.BasestationRepo)) - - // Create propagation service (depends on Server as AttachPropagateSender) - propagationSvc := bssciservices.NewPropagationService( - bssciInfra.EndpointRepo, - bssciServer, - infra.LoggerIface, - ) - bssciServer.SetPropagationService(propagationSvc) - - // Create and inject downlink dispatcher for auto-dispatch on dlOpen - downlinkDispatcher := bssciservices.NewDownlinkDispatcher( - infra.LoggerIface, - infra.Storage, - bssciServer.SendDLDataQueue, - ) - bssciServer.SetDownlinkDispatcher(downlinkDispatcher) - log.Info("Downlink auto-dispatch enabled (BSSCI §5.10.2)") - // Initialize roaming service based on configuration var roamingSvc bssci.RoamingService if cfg.Protocol.Roaming.Enabled { @@ -191,63 +153,135 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol log.Info("Roaming DISABLED - using noop service") roamingSvc = bssciservices.NewNoopRoamingService() } - bssciServer.SetRoamingService(roamingSvc) - // Wire ingress disposition resolver. - // In CE mode with federation enabled, unknown endpoints are relayed; otherwise they are dropped. - // Relay is initially disabled; it is enabled at runtime once onboarding completes. + // Ingress disposition resolver: in CE mode with federation enabled, + // unknown endpoints are relayed; otherwise dropped. Relay starts disabled + // and is enabled at runtime once onboarding completes. dispositionResolver := federationservices.NewDispositionResolver(bssciInfra.EndpointRepo, false) - bssciServer.SetDispositionResolver(dispositionResolver) log.Info("Ingress disposition resolver wired", "edition", cfg.General.Edition) - // Wire shared uplink ingest service (dedup → tenant resolution → persist → SCACI → MQTT) + // Blueprint resolver and decoder for automatic payload decoding on uplinks + resolverSvc := blueprintresolver.NewResolverService(infra.LoggerIface, infra.Storage.Blueprints(), infra.Storage.DeviceModels(), infra.Storage.EndPoints()) + decoderSvc := blueprintresolver.NewDecoderService(infra.LoggerIface) + + // MQTT event publisher for outbound device events (optional) + var mqttAdapter bssci.MQTTEventPublisher + if infra.MQTTClient != nil { + mqttPub := mqtt.NewPublisher(infra.MQTTClient, cfg.MQTT.TopicPrefix) + mqttAdapter = bssciservices.NewMQTTAdapter(mqttPub) + log.Info("MQTT event publisher wired to BSSCI server") + } + + // Shared uplink ingest service (dedup → tenant resolution → persist → SCACI → MQTT) uplinkIngestSvc := bssciservices.NewUplinkIngestService( - bssciServer.GetDeduplicator(), + deduplicator, bssciInfra.Storage, bssciInfra.OrgResolver, roamingSvc, bssciInfra.EndpointRepo, - nil, // blueprintResolver injected after construction - nil, // blueprintDecoder injected after construction + resolverSvc, + decoderSvc, bssciSvcBundle.Broadcaster, - nil, // mqttPublisher injected after construction + mqttAdapter, infra.LoggerIface, infra.TenantID, 0, // syntheticFederationBsEUI: zero until ECE federation-ingress is configured ) - bssciServer.SetUplinkIngestService(uplinkIngestSvc) - // Wire detach signature validator if enabled + // Detach signature validator (feature-controlled) + var detachValidator bssci.DetachSignatureValidator if cfg.Protocol.DetachSignatureValidationEnabled { log.Info("Detach signature validation ENABLED - initializing direct repository adapter") - detachValidator := bssciservices.NewDetachValidatorDirectAdapter( + detachValidator = bssciservices.NewDetachValidatorDirectAdapter( infra.Storage.EndPoints(), log, ) - bssciServer.SetDetachValidator(detachValidator) log.Info("Detach validator wired to BSSCI server (direct repository mode)") } else { log.Info("Detach signature validation DISABLED - unknown endpoint detach will be rejected") } - // Wire blueprint resolver and decoder for automatic payload decoding on uplinks - resolverSvc := blueprintresolver.NewResolverService(infra.LoggerIface, infra.Storage.Blueprints(), infra.Storage.DeviceModels(), infra.Storage.EndPoints()) - bssciServer.SetBlueprintResolver(resolverSvc) - decoderSvc := blueprintresolver.NewDecoderService(infra.LoggerIface) - bssciServer.SetBlueprintDecoder(decoderSvc) - uplinkIngestSvc.SetBlueprintResolver(resolverSvc) - uplinkIngestSvc.SetBlueprintDecoder(decoderSvc) - log.Info("Blueprint resolver and decoder wired to BSSCI server and ingest service") + // CE federation relay outbox writer (feature-controlled; the relay client + // itself is wired after Start alongside onboarding) + var relayOutboxWriter bssci.RelayOutboxWriter + if cfg.General.Edition == pkgconfig.EditionCommunity && cfg.Protocol.Federation.Enabled { + relayOutboxWriter = federationservices.NewOutboxWriter( + postgres.NewFederationOutboxRepository(infra.SqlxDB), infra.LoggerIface) + } - // Wire MQTT event publisher to BSSCI server for outbound device events - if infra.MQTTClient != nil { - mqttPub := mqtt.NewPublisher(infra.MQTTClient, cfg.MQTT.TopicPrefix) - mqttAdapter := bssciservices.NewMQTTAdapter(mqttPub) - bssciServer.SetMQTTPublisher(mqttAdapter) - uplinkIngestSvc.SetMQTTPublisher(mqttAdapter) - log.Info("MQTT event publisher wired to BSSCI server") + bssciServer, err := bssci.NewServer(bssciConfig, infra.LoggerIface, bssci.Dependencies{ + SessionSvc: bssciSvcBundle.SessionSvc, + VersionNegotiator: bssciSvcBundle.VersionNegotiator, + DownlinkSvc: bssciSvcBundle.DownlinkSvc, + StatusSvc: bssciSvcBundle.StatusSvc, + ConnectionRegistry: bssciSvcBundle.ConnectionSvc, + Broadcaster: bssciSvcBundle.Broadcaster, + QueueSerializer: bssciSvcBundle.QueueSerializer, + AuditLogger: bssciSvcBundle.AuditLogger, + TenantResolver: bssciSvcBundle.TenantResolver, + + EventStore: bssciInfra.SystemEventStore, + BaseStations: bssciInfra.BasestationRepo, + Endpoints: bssciInfra.EndpointRepo, + AttachPersistence: bssciservices.NewEndpointAttachmentPersistence( + bssciInfra.Storage, bssciInfra.BasestationRepo, log), + OrgDirectory: bssciInfra.OrgResolver, + KeyProtector: keyProtector, + + // Certificate identity: the CE composite resolver handles EUI CNs + // against the registered stations and delegates org- CNs to the + // deployment's org resolver; the directory backs connect-time + // fingerprint enforcement + CertIdentityResolver: bssciservices.NewCertificateIdentityResolver( + bssciInfra.BasestationRepo, + bssciInfra.OrgResolver, + infra.LoggerIface, + ), + BaseStationDirectory: bssciservices.NewRegisteredBaseStationDirectory(bssciInfra.BasestationRepo), + + UplinkIngest: uplinkIngestSvc, + RoamingSvc: roamingSvc, + DispositionResolver: dispositionResolver, + RelayOutbox: relayOutboxWriter, + + DetachValidator: detachValidator, + MQTTPublisher: mqttAdapter, + BlueprintDecoder: decoderSvc, + BlueprintResolver: resolverSvc, + SCACIEPStatusBroadcaster: bssciSvcBundle.EPStatusBroadcaster, + + ProtocolMessages: infra.Storage.MIOTYMessages(), + DLRXStatus: infra.Storage.DLRXStatus(), + BaseStationStatus: infra.Storage.MIOTYBaseStationStatus(), + DownlinkQueue: infra.Storage.MIOTYDownlinks(), + + TenantID: infra.TenantID, + DefaultTenantID: bssciInfra.FallbackTenantID, + }) + if err != nil { + return nil, fmt.Errorf("failed to create BSSCI server: %w (StatusService is mandatory for pending operation tracking)", err) } + // Circular dependencies constructed against the live server, injected once + // before Start + propagationSvc := bssciservices.NewPropagationService( + bssciInfra.EndpointRepo, + bssciServer, + infra.LoggerIface, + ) + downlinkDispatcher := bssciservices.NewDownlinkDispatcher( + infra.LoggerIface, + infra.Storage, + bssciServer.SendDLDataQueue, + ) + if err := bssciServer.ConfigureRuntime(bssci.RuntimeDependencies{ + Propagation: propagationSvc, + DownlinkDispatcher: downlinkDispatcher, + }); err != nil { + return nil, fmt.Errorf("failed to configure BSSCI server runtime: %w", err) + } + log.Info("Downlink auto-dispatch enabled (BSSCI §5.10.2)") + if err := bssciServer.Start(); err != nil { return nil, fmt.Errorf("failed to start mandatory BSSCI server: %w (cannot comply with MIOTY specification without TLS BSSCI endpoint)", err) } @@ -297,8 +331,6 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol sqlxDB := infra.SqlxDB outboxRepo := postgres.NewFederationOutboxRepository(sqlxDB) installRepo := postgres.NewCEInstallationRepository(sqlxDB) - outboxWriter := federationservices.NewOutboxWriter(outboxRepo, infra.LoggerIface) - bssciServer.SetRelayOutboxWriter(outboxWriter) relayClient := federationservices.NewRelayClient( cfg.Protocol.Federation, @@ -432,13 +464,11 @@ func buildSCACIServer( bssciInfra.OrgResolver, // orgResolver (BSSCI/SCACI org context parity) bssciServer, // sessionSnapshotProvider propagationSvc, // propagationSvc (BSSCI §5.8-5.8.3) + scaciSvcBundle.ErrorRecorder, ) if err != nil { return nil, fmt.Errorf("failed to create SCACI server: %w", err) } - - // Wire ErrorRecorder for §3.14 error persistence and event emission - scaciServer.SetErrorRecorder(scaciSvcBundle.ErrorRecorder) log.Info("SCACI ErrorRecorder wired for §3.14 compliance") if err := scaciServer.Start(); err != nil { @@ -466,9 +496,8 @@ func buildSCACIServer( } adapter.SetSCACIServer(scaciServer) log.Info("BSSCI→SCACI EPStatus adapter wired") - - // Wire to bssciServer for direct forwarding from attach/detach handlers - bssciServer.SetSCACIEPStatusBroadcaster(bssciSvcBundle.EPStatusBroadcaster) + // The BSSCI server already holds the bundle's EPStatus broadcaster (a + // constructor dependency); completing the adapter above activates it. return scaciServer, nil } diff --git a/KC-Core/internal/grpc/core_service_dldata_test.go b/KC-Core/internal/grpc/core_service_dldata_test.go index ce46c92..7676651 100644 --- a/KC-Core/internal/grpc/core_service_dldata_test.go +++ b/KC-Core/internal/grpc/core_service_dldata_test.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "encoding/json" "reflect" "testing" "time" @@ -299,6 +300,18 @@ func (f *fakeStatusSvc) ExtractQueueMetadata(_ *bssci.Session, _ int64) (uint64, return 0, 0, "" } +func (f *fakeStatusSvc) UpdatePendingOperationMetadata(_ context.Context, _ *bssci.Session, _ int64, _ map[string]interface{}, _ json.RawMessage) error { + return nil +} + +func (f *fakeStatusSvc) PersistedOperations(_ context.Context, _ int64) ([]bssci.PersistedOperation, error) { + return nil, nil +} + +func (f *fakeStatusSvc) DeletePendingOperations(_ context.Context, _ *bssci.Session) (int64, error) { + return 0, nil +} + // fakeDownlinkCmd implements bssci.DownlinkCommander (minimal for SendDownlink tests) type fakeDownlinkCmd struct { lastPacketCnt []int64 // Captures packet counter values sent to base station diff --git a/KC-Core/internal/grpc/core_service_test.go b/KC-Core/internal/grpc/core_service_test.go index bc0591f..30c2091 100644 --- a/KC-Core/internal/grpc/core_service_test.go +++ b/KC-Core/internal/grpc/core_service_test.go @@ -3,6 +3,7 @@ package grpc import ( "context" "encoding/binary" + "encoding/json" "errors" "fmt" "testing" @@ -2256,6 +2257,18 @@ func (s *stubStatusSvc) ExtractQueueMetadata(_ *bssci.Session, _ int64) (uint64, return 0, 0, "" } +func (s *stubStatusSvc) UpdatePendingOperationMetadata(_ context.Context, _ *bssci.Session, _ int64, _ map[string]interface{}, _ json.RawMessage) error { + return nil +} + +func (s *stubStatusSvc) PersistedOperations(_ context.Context, _ int64) ([]bssci.PersistedOperation, error) { + return nil, nil +} + +func (s *stubStatusSvc) DeletePendingOperations(_ context.Context, _ *bssci.Session) (int64, error) { + return 0, nil +} + type stubDownlinkScheduler struct{} func (s *stubDownlinkScheduler) QueueDownlink(_ context.Context, _ *mioty.DLDataQueue, _ int64) (uint64, uint64, error) { diff --git a/KC-Core/internal/services/bssci/connection_service.go b/KC-Core/internal/services/bssci/connection_service.go index 5f8e5a7..0e4c777 100644 --- a/KC-Core/internal/services/bssci/connection_service.go +++ b/KC-Core/internal/services/bssci/connection_service.go @@ -64,3 +64,20 @@ func (c *connectionRegistry) RegisterConnection(ctx context.Context, session *bs return nil } + +// DisconnectBaseStationIfCurrent marks the base station offline only while the +// given connection is still its current one. +func (c *connectionRegistry) DisconnectBaseStationIfCurrent(ctx context.Context, eui [8]byte, connectionID string) error { + if c.manager == nil { + return nil + } + return c.manager.DisconnectBaseStationIfCurrent(ctx, eui, connectionID) +} + +// UpdateLastSeen refreshes the base station's last-seen timestamp. +func (c *connectionRegistry) UpdateLastSeen(ctx context.Context, eui [8]byte) error { + if c.manager == nil { + return nil + } + return c.manager.UpdateLastSeen(ctx, eui) +} diff --git a/KC-Core/internal/services/bssci/downlink_dispatcher.go b/KC-Core/internal/services/bssci/downlink_dispatcher.go index 9fd8942..8dc5a7c 100644 --- a/KC-Core/internal/services/bssci/downlink_dispatcher.go +++ b/KC-Core/internal/services/bssci/downlink_dispatcher.go @@ -68,7 +68,7 @@ func NewDownlinkDispatcher( // 2. Send via SendDLDataQueue (dlRxStatQry pairing included) // 3. Idempotent reserved→queued confirmation on the regular repository func (d *downlinkDispatcher) DispatchIfAvailable( - ownerCtx context.Context, + ctx context.Context, ownerTenantID int64, ownerOrgUUID uuid.UUID, session *bssci.Session, @@ -78,7 +78,7 @@ func (d *downlinkDispatcher) DispatchIfAvailable( ) (bool, error) { // Guard: No tenant means we can't query the queue safely if ownerTenantID == 0 { - d.logger.WarnContext(ownerCtx, bssci.LogDispatcherNoTenant, "epEui", epEUI) + d.logger.WarnContext(ctx, bssci.LogDispatcherNoTenant, "epEui", epEUI) return false, nil } @@ -87,9 +87,9 @@ func (d *downlinkDispatcher) DispatchIfAvailable( binary.BigEndian.PutUint64(epEUIBytes, epEUI) // Short reservation transaction: reserve and commit before any wire write - tx, err := d.storage.BeginTx(ownerCtx) + tx, err := d.storage.BeginTx(ctx) if err != nil { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherTxBeginFailed, "error", err) + d.logger.ErrorContext(ctx, bssci.LogDispatcherTxBeginFailed, "error", err) return false, nil // Graceful degradation - don't fail uplink } rollback := true @@ -101,23 +101,23 @@ func (d *downlinkDispatcher) DispatchIfAvailable( // Atomic select + reserve with SKIP LOCKED // Pass nil for orgID since BSSCI dispatcher uses tenant-level isolation - dl, err := tx.MIOTYDownlinks().ReserveNextPendingDownlink(ownerCtx, ownerTenantID, epEUIBytes, session.BaseStationEUI, nil) + dl, err := tx.MIOTYDownlinks().ReserveNextPendingDownlink(ctx, ownerTenantID, epEUIBytes, session.BaseStationEUI, nil) if err != nil { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherQueryFailed, "error", err, "epEui", epEUI) + d.logger.ErrorContext(ctx, bssci.LogDispatcherQueryFailed, "error", err, "epEui", epEUI) return false, nil } if dl == nil { - d.logger.DebugContext(ownerCtx, bssci.LogDispatcherNoPending, "epEui", epEUI) + d.logger.DebugContext(ctx, bssci.LogDispatcherNoPending, "epEui", epEUI) return false, nil // No pending downlinks - normal case } if err := tx.Commit(); err != nil { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherTxCommitFailed, "error", err) + d.logger.ErrorContext(ctx, bssci.LogDispatcherTxCommitFailed, "error", err) return false, nil } rollback = false - return d.dispatchReserved(ownerCtx, ownerTenantID, ownerOrgUUID, session, epEUI, dl) + return d.dispatchReserved(ctx, ownerTenantID, ownerOrgUUID, session, epEUI, dl) } // DispatchQueue reserves one exact pending queue row (by queue ID, tenant, and @@ -126,7 +126,7 @@ func (d *downlinkDispatcher) DispatchIfAvailable( // with a nil error when no matching row is in 'pending' state (already // dispatched, revoked, or foreign). func (d *downlinkDispatcher) DispatchQueue( - ownerCtx context.Context, + ctx context.Context, ownerTenantID int64, ownerOrgUUID uuid.UUID, session *bssci.Session, @@ -134,7 +134,7 @@ func (d *downlinkDispatcher) DispatchQueue( epEUI uint64, ) (bool, error) { if ownerTenantID == 0 { - d.logger.WarnContext(ownerCtx, bssci.LogDispatcherNoTenant, "epEui", epEUI) + d.logger.WarnContext(ctx, bssci.LogDispatcherNoTenant, "epEui", epEUI) return false, nil } @@ -144,17 +144,17 @@ func (d *downlinkDispatcher) DispatchQueue( // Single-statement exact reservation (pending → reserved), atomic without // an explicit transaction. Org filter stays nil: BSSCI dispatch uses // tenant-level isolation, matching DispatchIfAvailable. - dl, err := d.storage.MIOTYDownlinks().ReservePendingDownlinkByQueueID(ownerCtx, ownerTenantID, nil, queueID, epEUIBytes, session.BaseStationEUI) + dl, err := d.storage.MIOTYDownlinks().ReservePendingDownlinkByQueueID(ctx, ownerTenantID, nil, queueID, epEUIBytes, session.BaseStationEUI) if err != nil { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherQueryFailed, "error", err, "epEui", epEUI, "queId", queueID) + d.logger.ErrorContext(ctx, bssci.LogDispatcherQueryFailed, "error", err, "epEui", epEUI, "queId", queueID) return false, err } if dl == nil { - d.logger.WarnContext(ownerCtx, bssci.LogDispatcherNoPending, "epEui", epEUI, "queId", queueID) + d.logger.WarnContext(ctx, bssci.LogDispatcherNoPending, "epEui", epEUI, "queId", queueID) return false, nil } - return d.dispatchReserved(ownerCtx, ownerTenantID, ownerOrgUUID, session, epEUI, dl) + return d.dispatchReserved(ctx, ownerTenantID, ownerOrgUUID, session, epEUI, dl) } // dispatchReserved sends a reserved queue row and confirms it as queued. The @@ -172,7 +172,7 @@ func (d *downlinkDispatcher) DispatchQueue( // reserved and the send is reported dispatched; the idempotent // confirmation is repeated from the dlDataQueRsp handler. func (d *downlinkDispatcher) dispatchReserved( - ownerCtx context.Context, + ctx context.Context, ownerTenantID int64, ownerOrgUUID uuid.UUID, session *bssci.Session, @@ -205,16 +205,16 @@ func (d *downlinkDispatcher) dispatchReserved( ) if err != nil { if errors.Is(err, bssci.ErrAmbiguousWrite) { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherSendFailed, + d.logger.ErrorContext(ctx, bssci.LogDispatcherSendFailed, "queId", dl.QueID, "epEui", epEUI, "error", err) return false, err } // Definite pre-write failure: release the reservation for retry - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherSendFailed, + d.logger.ErrorContext(ctx, bssci.LogDispatcherSendFailed, "queId", dl.QueID, "epEui", epEUI, "error", err) - if relErr := d.storage.MIOTYDownlinks().UpdateDownlinkStatus(ownerCtx, + if relErr := d.storage.MIOTYDownlinks().UpdateDownlinkStatus(ctx, strconv.FormatInt(dl.ID, 10), bssci.DLQueueStatusPending, nil); relErr != nil { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherReleaseFailed, + d.logger.ErrorContext(ctx, bssci.LogDispatcherReleaseFailed, "queId", dl.QueID, "error", relErr) } return false, err @@ -225,7 +225,7 @@ func (d *downlinkDispatcher) dispatchReserved( // packetCnt = nil (unknown until the dlDataRes transmission result, BSSCI 5.14) txTime := time.Now().UnixNano() if err := d.storage.MIOTYDownlinks().MarkReservedAsQueued( - ownerCtx, + ctx, uint64(dl.QueID), //nolint:gosec // G115: QueID is always positive (DB-assigned sequence) ownerTenantID, session.BaseStationEUI, @@ -235,12 +235,12 @@ func (d *downlinkDispatcher) dispatchReserved( ); err != nil { // The send happened: report dispatched, leave the row reserved. The // dlDataQueRsp handler repeats the idempotent confirmation. - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherMarkSentFailed, + d.logger.ErrorContext(ctx, bssci.LogDispatcherMarkSentFailed, "queId", dl.QueID, "error", err) return true, nil } - d.logger.InfoContext(ownerCtx, bssci.LogDispatcherSuccess, + d.logger.InfoContext(ctx, bssci.LogDispatcherSuccess, "queId", dl.QueID, "epEui", epEUI, "bsEui", session.BaseStationEUI, diff --git a/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go b/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go new file mode 100644 index 0000000..508ab34 --- /dev/null +++ b/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go @@ -0,0 +1,160 @@ +package bssciservices + +import ( + "context" + "errors" + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// persistEndpointRepo records the transactional endpoint field update. +type persistEndpointRepo struct { + interfaces.EndpointRepository + updates map[string]interface{} + updateErr error +} + +func (r *persistEndpointRepo) UpdateFields(_ context.Context, _ int64, _ int64, updates map[string]interface{}) error { + r.updates = updates + return r.updateErr +} + +// persistSessionRepo records the endpoint-session upsert. +type persistSessionRepo struct { + interfaces.EndPointSessionRepository + active *models.EndPointSession + created *models.EndPointSession + updated *models.EndPointSession +} + +func (r *persistSessionRepo) GetActive(_ context.Context, _ string) (*models.EndPointSession, error) { + return r.active, nil +} + +func (r *persistSessionRepo) Create(_ context.Context, s *models.EndPointSession) error { + r.created = s + return nil +} + +func (r *persistSessionRepo) Update(_ context.Context, s *models.EndPointSession) error { + r.updated = s + return nil +} + +// persistTx implements interfaces.Transaction over the two recording repos. +type persistTx struct { + interfaces.Transaction + endpoints *persistEndpointRepo + sessions *persistSessionRepo + committed bool + rolledBack bool +} + +func (t *persistTx) EndPoints() interfaces.EndpointRepository { return t.endpoints } +func (t *persistTx) EndPointSessions() interfaces.EndPointSessionRepository { return t.sessions } +func (t *persistTx) Commit() error { t.committed = true; return nil } +func (t *persistTx) Rollback() error { t.rolledBack = true; return nil } + +// persistStorage hands out the single transaction under test. +type persistStorage struct { + interfaces.Storage + tx *persistTx + beginErr error +} + +func (s *persistStorage) BeginTx(_ context.Context) (interfaces.Transaction, error) { + if s.beginErr != nil { + return nil, s.beginErr + } + return s.tx, nil +} + +func newPersistFixture() (*persistStorage, *persistTx) { + tx := &persistTx{endpoints: &persistEndpointRepo{}, sessions: &persistSessionRepo{}} + return &persistStorage{tx: tx}, tx +} + +// TestPersistAttachSession_CreatesSessionAndCommits: with no active session a +// new endpoint-session row is created carrying the attach counter and short +// address, and the transaction commits. +func TestPersistAttachSession_CreatesSessionAndCommits(t *testing.T) { + st, tx := newPersistFixture() + p := NewEndpointAttachmentPersistence(st, nil, logger.NewNop()) + + err := p.PersistAttachSession(testutil.TestContext(), bssci.AttachSessionRecord{ + TenantID: 7, + EndpointID: 42, + EndpointUpdates: map[string]interface{}{"attached": true}, + EncryptedKey: []byte{1, 2, 3}, + AttachCnt: 9, + ShAddr: 0x1505, + }) + + require.NoError(t, err) + assert.True(t, tx.committed, "transaction must commit") + assert.False(t, tx.rolledBack) + require.NotNil(t, tx.sessions.created) + assert.Equal(t, int64(7), tx.sessions.created.TenantID) + assert.Equal(t, int32(9), tx.sessions.created.AttachCnt) + require.NotNil(t, tx.sessions.created.ShAddr) + assert.Equal(t, int32(0x1505), *tx.sessions.created.ShAddr) + assert.Equal(t, map[string]interface{}{"attached": true}, tx.endpoints.updates) +} + +// TestPersistAttachSession_UpdateFailureRollsBack: an endpoint update failure +// rolls the transaction back and propagates the raw error. +func TestPersistAttachSession_UpdateFailureRollsBack(t *testing.T) { + st, tx := newPersistFixture() + updateErr := errors.New("update failed") + tx.endpoints.updateErr = updateErr + p := NewEndpointAttachmentPersistence(st, nil, logger.NewNop()) + + err := p.PersistAttachSession(testutil.TestContext(), bssci.AttachSessionRecord{TenantID: 1, EndpointID: 1}) + + require.ErrorIs(t, err, updateErr) + assert.True(t, tx.rolledBack, "failed transaction must roll back") + assert.False(t, tx.committed) + assert.Nil(t, tx.sessions.created, "no session row after a failed endpoint update") +} + +// TestPersistAttachPropagateSession_UpdatesActiveSession: an existing active +// session is updated (never duplicated) and the wrapped error strings of the +// propagate path stay intact on begin failure. +func TestPersistAttachPropagateSession_UpdatesActiveSession(t *testing.T) { + st, tx := newPersistFixture() + tx.sessions.active = &models.EndPointSession{ID: 5, TenantID: 3} + p := NewEndpointAttachmentPersistence(st, nil, logger.NewNop()) + + err := p.PersistAttachPropagateSession(testutil.TestContext(), bssci.AttachPropagateSessionRecord{ + TenantID: 3, + EndpointID: 42, + EncryptedKey: []byte{9}, + ShAddr: 0x22, + }) + + require.NoError(t, err) + assert.True(t, tx.committed) + require.NotNil(t, tx.sessions.updated, "active session must be updated in place") + assert.Nil(t, tx.sessions.created) + assert.Equal(t, []byte{9}, tx.sessions.updated.SessionKey) +} + +// TestPersistAttachPropagateSession_BeginFailureKeepsErrorString: the caller +// depends on the exact wrapped error text of the propagate path. +func TestPersistAttachPropagateSession_BeginFailureKeepsErrorString(t *testing.T) { + st, _ := newPersistFixture() + st.beginErr = errors.New("db down") + p := NewEndpointAttachmentPersistence(st, nil, logger.NewNop()) + + err := p.PersistAttachPropagateSession(testutil.TestContext(), bssci.AttachPropagateSessionRecord{}) + + require.Error(t, err) + assert.Equal(t, "failed to begin attach propagate transaction: db down", err.Error()) +} diff --git a/KC-Core/internal/services/bssci/endpoint_attachment_service.go b/KC-Core/internal/services/bssci/endpoint_attachment_service.go index 74a2b0d..f684a88 100644 --- a/KC-Core/internal/services/bssci/endpoint_attachment_service.go +++ b/KC-Core/internal/services/bssci/endpoint_attachment_service.go @@ -17,6 +17,7 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + "github.com/google/uuid" ) // AttachPropagateSender defines the interface for sending attach propagation. @@ -392,3 +393,185 @@ var ( // ErrInvalidNetworkKeyLength indicates network key is not 16 bytes ErrInvalidNetworkKeyLength = errors.New("invalid network key length") ) + +// endpointAttachmentPersistence is the transactional owner of attach and +// attach-propagate endpoint-session persistence (BSSCI rev1 §5.7-§5.8 / +// classic §3.7-§3.8). +type endpointAttachmentPersistence struct { + storage interfaces.Storage + basestationRepo interfaces.BaseStationRepository + logger logger.Logger +} + +// NewEndpointAttachmentPersistence creates the persister over the storage +// facade; the base station repository serves the out-of-transaction primary +// station lookup. +func NewEndpointAttachmentPersistence(storage interfaces.Storage, basestationRepo interfaces.BaseStationRepository, log logger.Logger) bssci.EndpointAttachmentPersistence { + return &endpointAttachmentPersistence{ + storage: storage, + basestationRepo: basestationRepo, + logger: log, + } +} + +// PersistAttachSession runs the attach transaction: endpoint field update, +// endpoint-session upsert (with attach counter and short address), commit. +func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context, rec bssci.AttachSessionRecord) error { + tx, txErr := p.storage.BeginTx(ctx) + if txErr != nil { + p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToBeginTransaction, "error", txErr) + return txErr + } + + var commitErr error + defer func() { + if commitErr != nil { + _ = tx.Rollback() + } + }() + + if err := tx.EndPoints().UpdateFields(ctx, rec.TenantID, rec.EndpointID, rec.EndpointUpdates); err != nil { + commitErr = err + p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateEndpointAttachMetadata, "error", err) + return err + } + + endpointIDStr := fmt.Sprintf("%d", rec.EndpointID) + activeSession, getErr := tx.EndPointSessions().GetActive(ctx, endpointIDStr) + if getErr != nil { + commitErr = getErr + p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToLoadEndpointSession, "error", getErr) + return getErr + } + + now := time.Now().UTC() + + // Lookup base station ID for session enrichment (tenant-scoped) + var primaryBsID *int64 + if p.basestationRepo != nil { + bs, bsErr := p.basestationRepo.GetByEUI(ctx, rec.BSLookupTenantID, rec.BaseStationEUI) + if bsErr == nil && bs != nil { + primaryBsID = &bs.ID + } + } + + // ShAddr is uint16, model ShAddr is *int32 (INTEGER 0-65535) + shAddrInt32 := int32(rec.ShAddr) + + if activeSession != nil { + activeSession.SessionKey = rec.EncryptedKey + //nolint:gosec // G115: AttachCnt is handler-validated to fit 24 bits + activeSession.AttachCnt = int32(rec.AttachCnt) + activeSession.LastActivityAt = now + activeSession.ShAddr = &shAddrInt32 + activeSession.PrimaryBaseStationID = primaryBsID + if err := tx.EndPointSessions().Update(ctx, activeSession); err != nil { + commitErr = err + p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateEndpointSession, "error", err) + return err + } + } else { + newSession := &models.EndPointSession{ + TenantID: rec.TenantID, + EndPointID: rec.EndpointID, + SessionID: uuid.New().String(), + SessionKey: rec.EncryptedKey, + //nolint:gosec // G115: AttachCnt is handler-validated to fit 24 bits + AttachCnt: int32(rec.AttachCnt), + Status: "active", + StartedAt: now, + LastActivityAt: now, + ShAddr: &shAddrInt32, + PrimaryBaseStationID: primaryBsID, + } + if err := tx.EndPointSessions().Create(ctx, newSession); err != nil { + commitErr = err + p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToCreateEndpointSession, "error", err) + return err + } + } + + if err := tx.Commit(); err != nil { + commitErr = err + p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToCommitAttachTransaction, "error", err) + return err + } + commitErr = nil + return nil +} + +// PersistAttachPropagateSession runs the attach-propagate transaction under +// the endpoint owner tenant; error strings propagate to the caller unchanged. +func (p *endpointAttachmentPersistence) PersistAttachPropagateSession(ctx context.Context, rec bssci.AttachPropagateSessionRecord) error { + tx, txErr := p.storage.BeginTx(ctx) + if txErr != nil { + return fmt.Errorf("failed to begin attach propagate transaction: %w", txErr) + } + var commitErr error + defer func() { + if commitErr != nil { + _ = tx.Rollback() + } + }() + + if err := tx.EndPoints().UpdateFields(ctx, rec.TenantID, rec.EndpointID, rec.EndpointUpdates); err != nil { + commitErr = err + return fmt.Errorf("failed to update endpoint: %w", err) + } + + endpointIDStr := fmt.Sprintf("%d", rec.EndpointID) + activeSession, getErr := tx.EndPointSessions().GetActive(ctx, endpointIDStr) + if getErr != nil { + commitErr = getErr + return fmt.Errorf("failed to load endpoint session: %w", getErr) + } + + now := time.Now().UTC() + + // Lookup base station ID for session enrichment (tenant-scoped to owner) + var primaryBsID *int64 + if p.basestationRepo != nil { + bs, bsErr := p.basestationRepo.GetByEUI(ctx, rec.TenantID, rec.BaseStationEUI) + if bsErr == nil && bs != nil { + primaryBsID = &bs.ID + } + } + + // ShAddr is uint16, model ShAddr is *int32 (INTEGER 0-65535) + shAddrInt32 := int32(rec.ShAddr) + + if activeSession != nil { + activeSession.SessionKey = rec.EncryptedKey + activeSession.LastActivityAt = now + activeSession.ShAddr = &shAddrInt32 + activeSession.PrimaryBaseStationID = primaryBsID + if err := tx.EndPointSessions().Update(ctx, activeSession); err != nil { + commitErr = err + return fmt.Errorf("failed to update endpoint session: %w", err) + } + } else { + newSession := &models.EndPointSession{ + TenantID: rec.TenantID, + EndPointID: rec.EndpointID, + SessionID: uuid.New().String(), + SessionKey: rec.EncryptedKey, + Status: string(models.SessionStatusActive), + UplinkMode: "standard", // Default uplink mode per BSSCI §5.2 + StartedAt: now, + LastActivityAt: now, + ShAddr: &shAddrInt32, + PrimaryBaseStationID: primaryBsID, + } + if err := tx.EndPointSessions().Create(ctx, newSession); err != nil { + commitErr = err + return fmt.Errorf("failed to create endpoint session: %w", err) + } + } + + if err := tx.Commit(); err != nil { + commitErr = err + return fmt.Errorf("failed to commit attach propagate transaction: %w", err) + } + commitErr = nil + return nil +} diff --git a/KC-Core/internal/services/bssci/session_service.go b/KC-Core/internal/services/bssci/session_service.go index d6735c6..836fc96 100644 --- a/KC-Core/internal/services/bssci/session_service.go +++ b/KC-Core/internal/services/bssci/session_service.go @@ -514,12 +514,12 @@ func (s *sessionService) UpdateSessionCounters(ctx context.Context, session *bss return fmt.Errorf("cannot update counters: session not persisted (DbSessionID=0)") } - updateReq := &models.BaseStationSessionUpdateRequest{ - SnBsOpId: &session.LastBsOpId, - SnScOpId: &session.LastScOpId, - } - - err := s.bsSessionRepo.UpdateSession(ctx, s.resolvedTenantID(session), session.DbSessionID, updateReq) + // Uses the fixed atomic UpdateOperationIDs statement (sets updated_at and + // errors when the session row is missing) so every counter-persistence + // path shares one durable semantics: a zero-row update surfaces as an + // error instead of silent success on an inconsistent session. + err := s.bsSessionRepo.UpdateOperationIDs(ctx, s.resolvedTenantID(session), session.DbSessionID, + session.LastBsOpId, session.LastScOpId) if err != nil { s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateDatabaseSession, "error", err, diff --git a/KC-Core/internal/services/bssci/status_service.go b/KC-Core/internal/services/bssci/status_service.go index bf9ad3b..e1f027d 100644 --- a/KC-Core/internal/services/bssci/status_service.go +++ b/KC-Core/internal/services/bssci/status_service.go @@ -201,6 +201,63 @@ func (s *statusService) RemovePendingOperation(ctx context.Context, session *bss // - tenantID: Tenant ID string extracted from metadata (empty string if not found) // // Thread Safety: Method handles mutex locking internally +// UpdatePendingOperationMetadata persists new metadata for an existing pending +// row and mirrors it into the cache only after the DB write succeeds. +func (s *statusService) UpdatePendingOperationMetadata(ctx context.Context, session *bssci.Session, opId int64, metadata map[string]interface{}, metadataJSON json.RawMessage) error { + if err := s.repo.UpdateMetadata(ctx, session.DbSessionID, opId, metadataJSON); err != nil { + s.logger.WarnContext(ctx, bssci.LogBSSCIFailedToUpdatePendingOperationMetadata, + "error", err, + "sessionID", session.DbSessionID, + "opId", opId) + return err + } + + key := bssci.SessionOpKey{SessionID: session.ID, OperationID: opId} + s.mu.Lock() + if pendingOp, ok := (*s.pendingOps)[key]; ok { + pendingOp.Metadata = metadata + } + s.mu.Unlock() + return nil +} + +// PersistedOperations returns the raw persisted rows for resume hydration. +func (s *statusService) PersistedOperations(ctx context.Context, sessionID int64) ([]bssci.PersistedOperation, error) { + rows, err := s.repo.GetBySession(ctx, sessionID) + if err != nil { + return nil, err + } + ops := make([]bssci.PersistedOperation, 0, len(rows)) + for _, row := range rows { + ops = append(ops, bssci.PersistedOperation{ + OperationID: row.OperationID, + OperationType: row.OperationType, + EndpointEUI: row.EndpointEUI, + OperationData: row.OperationData, + Metadata: row.Metadata, + CreatedAt: row.CreatedAt, + }) + } + return ops, nil +} + +// DeletePendingOperations removes the session's persisted rows and, only on +// success, evicts its cached operations (keyed by the runtime session ID). +func (s *statusService) DeletePendingOperations(ctx context.Context, session *bssci.Session) (int64, error) { + count, err := s.repo.DeleteBySession(ctx, session.DbSessionID) + if err != nil { + return 0, err + } + s.mu.Lock() + for key := range *s.pendingOps { + if key.SessionID == session.ID { + delete(*s.pendingOps, key) + } + } + s.mu.Unlock() + return count, nil +} + func (s *statusService) ExtractQueueMetadata(session *bssci.Session, opId int64) (endpointEUI uint64, queueID int64, tenantID string) { key := bssci.SessionOpKey{ SessionID: session.ID, diff --git a/KC-Core/internal/services/scaci/certificate_verifier.go b/KC-Core/internal/services/scaci/certificate_verifier.go index 6ec75a2..186ebd3 100644 --- a/KC-Core/internal/services/scaci/certificate_verifier.go +++ b/KC-Core/internal/services/scaci/certificate_verifier.go @@ -21,6 +21,7 @@ package scaciservices import ( + "context" "crypto/x509" "time" @@ -70,9 +71,9 @@ func NewCertificateVerifier( // // Returns: // - string: Error token if validation fails, "" on success -func (cv *certificateVerifier) VerifyCertificate(cert *x509.Certificate) string { +func (cv *certificateVerifier) VerifyCertificate(ctx context.Context, cert *x509.Certificate) string { if cert == nil { - cv.logger.Error(scaci.LogSCACINoClientCertificate) + cv.logger.ErrorContext(ctx, scaci.LogSCACINoClientCertificate) return scaci.ErrNilCertificate } @@ -80,7 +81,7 @@ func (cv *certificateVerifier) VerifyCertificate(cert *x509.Certificate) string // Check 1: Certificate expiry validation if now.Before(cert.NotBefore) { - cv.logger.Error(scaci.LogSCACICertNotYetValid, + cv.logger.ErrorContext(ctx, scaci.LogSCACICertNotYetValid, "notBefore", cert.NotBefore, "now", now, "subject", cert.Subject.String()) @@ -88,7 +89,7 @@ func (cv *certificateVerifier) VerifyCertificate(cert *x509.Certificate) string } if now.After(cert.NotAfter) { - cv.logger.Error(scaci.LogSCACICertExpired, + cv.logger.ErrorContext(ctx, scaci.LogSCACICertExpired, "notAfter", cert.NotAfter, "now", now, "subject", cert.Subject.String()) @@ -105,7 +106,7 @@ func (cv *certificateVerifier) VerifyCertificate(cert *x509.Certificate) string } if !hasClientAuth { - cv.logger.Error(scaci.LogSCACICertMissingClientAuth, + cv.logger.ErrorContext(ctx, scaci.LogSCACICertMissingClientAuth, "subject", cert.Subject.String(), "extKeyUsage", cert.ExtKeyUsage) return scaci.ErrCertMissingClientAuth @@ -113,12 +114,12 @@ func (cv *certificateVerifier) VerifyCertificate(cert *x509.Certificate) string // Check 3: Subject validation (basic presence check) if cert.Subject.CommonName == "" && len(cert.Subject.Organization) == 0 { - cv.logger.Error(scaci.LogSCACICertInvalidSubject, + cv.logger.ErrorContext(ctx, scaci.LogSCACICertInvalidSubject, "subject", cert.Subject.String()) return scaci.ErrCertInvalidSubject } - cv.logger.Debug(scaci.LogSCACICertValidationPassed, + cv.logger.DebugContext(ctx, scaci.LogSCACICertValidationPassed, "subject", cert.Subject.String(), "cn", cert.Subject.CommonName, "notBefore", cert.NotBefore, diff --git a/KC-Core/internal/services/scaci/certificate_verifier_test.go b/KC-Core/internal/services/scaci/certificate_verifier_test.go index 2107fe0..2a06824 100644 --- a/KC-Core/internal/services/scaci/certificate_verifier_test.go +++ b/KC-Core/internal/services/scaci/certificate_verifier_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/scaci" ) @@ -44,7 +46,7 @@ func TestVerifyCertificate_Valid(t *testing.T) { pkix.Name{CommonName: "test-client", Organization: []string{"TestOrg"}}, // Valid subject ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != "" { t.Errorf("expected valid certificate to pass, got error token: %s", errToken) } @@ -54,7 +56,7 @@ func TestVerifyCertificate_Nil(t *testing.T) { logger := logger.NewNop() verifier := NewCertificateVerifier(logger) - errToken := verifier.VerifyCertificate(nil) + errToken := verifier.VerifyCertificate(testutil.TestContext(), nil) if errToken != scaci.ErrNilCertificate { t.Errorf("expected ErrNilCertificate, got: %s", errToken) } @@ -73,7 +75,7 @@ func TestVerifyCertificate_NotYetValid(t *testing.T) { pkix.Name{CommonName: "test-client"}, ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != scaci.ErrCertNotYetValid { t.Errorf("expected ErrCertNotYetValid, got: %s", errToken) } @@ -92,7 +94,7 @@ func TestVerifyCertificate_Expired(t *testing.T) { pkix.Name{CommonName: "test-client"}, ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != scaci.ErrCertExpired { t.Errorf("expected ErrCertExpired, got: %s", errToken) } @@ -111,7 +113,7 @@ func TestVerifyCertificate_MissingClientAuth(t *testing.T) { pkix.Name{CommonName: "test-client"}, ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != scaci.ErrCertMissingClientAuth { t.Errorf("expected ErrCertMissingClientAuth, got: %s", errToken) } @@ -130,7 +132,7 @@ func TestVerifyCertificate_NoExtKeyUsage(t *testing.T) { pkix.Name{CommonName: "test-client"}, ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != scaci.ErrCertMissingClientAuth { t.Errorf("expected ErrCertMissingClientAuth, got: %s", errToken) } @@ -149,7 +151,7 @@ func TestVerifyCertificate_InvalidSubject_Empty(t *testing.T) { pkix.Name{}, // Empty subject ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != scaci.ErrCertInvalidSubject { t.Errorf("expected ErrCertInvalidSubject, got: %s", errToken) } @@ -168,7 +170,7 @@ func TestVerifyCertificate_ValidSubject_OnlyOrganization(t *testing.T) { pkix.Name{Organization: []string{"TestOrg"}}, // Only org, no CN ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != "" { t.Errorf("expected valid certificate (org without CN), got error token: %s", errToken) } @@ -191,7 +193,7 @@ func TestVerifyCertificate_MultipleExtKeyUsages(t *testing.T) { pkix.Name{CommonName: "test-client"}, ) - errToken := verifier.VerifyCertificate(cert) + errToken := verifier.VerifyCertificate(testutil.TestContext(), cert) if errToken != "" { t.Errorf("expected valid certificate (multiple usages), got error token: %s", errToken) } diff --git a/KC-Core/internal/services/scaci/endpoint_service.go b/KC-Core/internal/services/scaci/endpoint_service.go index 7d5ce5c..7180530 100644 --- a/KC-Core/internal/services/scaci/endpoint_service.go +++ b/KC-Core/internal/services/scaci/endpoint_service.go @@ -382,9 +382,9 @@ func (es *endpointService) GetGlobal( // // Returns: // - []error: Slice of errors (one per failed BS), empty if all succeeded or propagator nil -func (es *endpointService) PropagateDetachToAll(epEui uint64) []error { +func (es *endpointService) PropagateDetachToAll(ctx context.Context, epEui uint64) []error { if es.detachPropagator == nil { - es.logger.Debug("DetachPropagator not available, skipping propagation", + es.logger.DebugContext(ctx, scaci.LogSCACIDetachPropagatorUnavailable, "epEui", pkgmioty.FormatEUI64(epEui)) return nil } diff --git a/KC-Core/internal/services/scaci/handshake_service.go b/KC-Core/internal/services/scaci/handshake_service.go index 3e942fb..ca4a7a9 100644 --- a/KC-Core/internal/services/scaci/handshake_service.go +++ b/KC-Core/internal/services/scaci/handshake_service.go @@ -156,7 +156,7 @@ func (hs *handshakeService) ValidateConnect( // Guard 3: Certificate security validation (defense-in-depth) // Verify certificate expiry, key usage, and subject BEFORE tenant resolution - if errToken := hs.certVerifier.VerifyCertificate(cert); errToken != "" { + if errToken := hs.certVerifier.VerifyCertificate(ctx, cert); errToken != "" { // Certificate validation failed - return error token return nil, nil, errToken } @@ -175,7 +175,7 @@ func (hs *handshakeService) ValidateConnect( } // Community mode: fall back to default tenant ID - hs.logger.WarnContext(ctx, "Certificate org resolution failed, using community fallback", + hs.logger.WarnContext(ctx, scaci.LogSCACICertOrgResolutionFallback, "error", err, "certCN", cert.Subject.CommonName, "defaultTenantID", hs.defaultTenantID) @@ -196,7 +196,7 @@ func (hs *handshakeService) ValidateConnect( "certCN", cert.Subject.CommonName) // Step 2: Version negotiation (§2.1-2.3) - negotiatedVersion, errToken := hs.NegotiateVersion(req.Version) + negotiatedVersion, errToken := hs.NegotiateVersion(ctx, req.Version) if errToken != "" { return nil, nil, errToken } @@ -287,11 +287,11 @@ func (hs *handshakeService) ValidateConnect( // TODO: Validate software version (non-fatal per SCACI §3.3.2 - swVersion is optional) switch hs.scSwVersion { case "": - hs.logger.WarnContext(ctx, "Software version not configured - ConnectResponse will omit swVersion field", + hs.logger.WarnContext(ctx, scaci.LogSCACISoftwareVersionNotConfigured, "tenantID", tenantID, "acEui", pkgmioty.FormatEUI64(req.AcEui)) case "dev", "dev-local": - hs.logger.WarnContext(ctx, "Using development software version in ConnectResponse", + hs.logger.WarnContext(ctx, scaci.LogSCACIUsingDevelopmentSoftwareVersion, "swVersion", hs.scSwVersion, "tenantID", tenantID, "acEui", pkgmioty.FormatEUI64(req.AcEui)) @@ -334,11 +334,11 @@ func (hs *handshakeService) ValidateConnect( // Returns: // - string: Negotiated version (e.g., "1.0.0") // - string: Error token (errInvalidVersionFormat, errMajorVersionUnsupported) or "" -func (hs *handshakeService) NegotiateVersion(clientVersion string) (string, string) { +func (hs *handshakeService) NegotiateVersion(ctx context.Context, clientVersion string) (string, string) { // Parse client version using existing helper reqMajor, reqMinor, _, err := scaci.ParseSemanticVersion(clientVersion) if err != nil { - hs.logger.Error(scaci.LogSCACIInvalidVersionFormat, + hs.logger.ErrorContext(ctx, scaci.LogSCACIInvalidVersionFormat, "version", clientVersion, "error", err) return "", scaci.ErrInvalidVersionFormat @@ -346,7 +346,7 @@ func (hs *handshakeService) NegotiateVersion(clientVersion string) (string, stri // SCACI §2.1: Major version must match if reqMajor != scaci.SupportedMajorVersion { - hs.logger.Error(scaci.LogSCACIMajorVersionMismatch, + hs.logger.ErrorContext(ctx, scaci.LogSCACIMajorVersionMismatch, "requested", clientVersion, "supported_major", scaci.SupportedMajorVersion) return "", scaci.ErrMajorVersionUnsupported @@ -354,7 +354,7 @@ func (hs *handshakeService) NegotiateVersion(clientVersion string) (string, stri // SCACI §2.2: Minor version compatibility if reqMinor > scaci.SupportedMinorVersion { - hs.logger.Error(scaci.LogSCACIMinorVersionTooHigh, + hs.logger.ErrorContext(ctx, scaci.LogSCACIMinorVersionTooHigh, "requested", clientVersion, "supported_minor", scaci.SupportedMinorVersion) return "", scaci.ErrMinorVersionUnsupported @@ -363,7 +363,7 @@ func (hs *handshakeService) NegotiateVersion(clientVersion string) (string, stri // SCACI §2.3: Ignore patch - use ProtocolVersionString from constants negotiatedVersion := scaci.ProtocolVersionString - hs.logger.Info(scaci.LogSCACIVersionNegotiationOk, + hs.logger.InfoContext(ctx, scaci.LogSCACIVersionNegotiationOk, "requested", clientVersion, "negotiated", negotiatedVersion) diff --git a/KC-Core/internal/services/scaci/handshake_service_test.go b/KC-Core/internal/services/scaci/handshake_service_test.go index fa8ba88..63dbc9e 100644 --- a/KC-Core/internal/services/scaci/handshake_service_test.go +++ b/KC-Core/internal/services/scaci/handshake_service_test.go @@ -115,7 +115,7 @@ type mockCertificateVerifier struct { verifyCertificateFunc func(cert *x509.Certificate) string } -func (m *mockCertificateVerifier) VerifyCertificate(cert *x509.Certificate) string { +func (m *mockCertificateVerifier) VerifyCertificate(_ context.Context, cert *x509.Certificate) string { if m.verifyCertificateFunc != nil { return m.verifyCertificateFunc(cert) } @@ -496,7 +496,7 @@ func TestNegotiateVersion_ValidVersion(t *testing.T) { 0x1122334455667788, "KiloCenter", "KC-1000", "test-sc", "1.0.0", ).(*handshakeService) - negotiated, errToken := svc.NegotiateVersion("1.0.0") + negotiated, errToken := svc.NegotiateVersion(testutil.TestContext(), "1.0.0") if errToken != "" { t.Errorf("expected no error, got: %s", errToken) @@ -517,7 +517,7 @@ func TestNegotiateVersion_MajorVersionMismatch(t *testing.T) { 0x1122334455667788, "KiloCenter", "KC-1000", "test-sc", "1.0.0", ).(*handshakeService) - negotiated, errToken := svc.NegotiateVersion("2.0.0") + negotiated, errToken := svc.NegotiateVersion(testutil.TestContext(), "2.0.0") if errToken != scaci.ErrMajorVersionUnsupported { t.Errorf("expected ErrMajorVersionUnsupported, got: %s", errToken) @@ -538,7 +538,7 @@ func TestNegotiateVersion_MinorVersionTooHigh(t *testing.T) { 0x1122334455667788, "KiloCenter", "KC-1000", "test-sc", "1.0.0", ).(*handshakeService) - negotiated, errToken := svc.NegotiateVersion("1.5.0") + negotiated, errToken := svc.NegotiateVersion(testutil.TestContext(), "1.5.0") if errToken != scaci.ErrMinorVersionUnsupported { t.Errorf("expected ErrMinorVersionUnsupported, got: %s", errToken) @@ -559,7 +559,7 @@ func TestNegotiateVersion_InvalidFormat(t *testing.T) { 0x1122334455667788, "KiloCenter", "KC-1000", "test-sc", "1.0.0", ).(*handshakeService) - negotiated, errToken := svc.NegotiateVersion("invalid-version") + negotiated, errToken := svc.NegotiateVersion(testutil.TestContext(), "invalid-version") if errToken != scaci.ErrInvalidVersionFormat { t.Errorf("expected ErrInvalidVersionFormat, got: %s", errToken) @@ -584,7 +584,7 @@ func TestNegotiateVersion_UsesPkgConstants(t *testing.T) { ).(*handshakeService) // Request with version matching SupportedMajorVersion.SupportedMinorVersion - negotiated, errToken := svc.NegotiateVersion("1.0.0") + negotiated, errToken := svc.NegotiateVersion(testutil.TestContext(), "1.0.0") if errToken != "" { t.Fatalf("expected successful negotiation, got error: %s", errToken) diff --git a/KC-Core/internal/services/scaci/session_persistence.go b/KC-Core/internal/services/scaci/session_persistence.go index c46cffe..8da7986 100644 --- a/KC-Core/internal/services/scaci/session_persistence.go +++ b/KC-Core/internal/services/scaci/session_persistence.go @@ -32,18 +32,49 @@ func NewSessionPersistence( } } +// connectSnapshot is the immutable copy of every session field the async +// persistence goroutine reads. Capturing it before the goroutine starts +// prevents races with caller mutations of the live session; all fields are +// value types, and metadata is deep-copied. +type connectSnapshot struct { + Resumed bool + ID int64 + TenantID int64 + OrganizationID uuid.UUID + AcEuiBytes [8]byte + SnAcUUID scaci.UUID16 + SnScUUID scaci.UUID16 + AcOpIdCounter int64 + ScOpIdCounter int64 + Metadata map[string]interface{} +} + // PersistConnectAsync handles async session creation/update after Connect handshake. // Encapsulates the persistence pattern in a service (handlers orchestrate, services execute). -func (p *sessionPersistence) PersistConnectAsync(session *scaci.Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) { - // CRITICAL: Deep-copy metadata BEFORE goroutine to prevent race with caller mutations. - // This is the ONLY copy - do not call deepCopyMetadata again inside the goroutine. - metadataCopy := deepCopyMetadata(session.Metadata) +// The caller's ctx contributes tenant/org log fields only: the goroutine detaches from +// its cancellation so persistence outlives the connection that triggered it. +func (p *sessionPersistence) PersistConnectAsync(ctx context.Context, session *scaci.Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) { + // CRITICAL: snapshot BEFORE the goroutine to prevent races with caller + // mutations; metadata is deep-copied exactly once. + snap := connectSnapshot{ + Resumed: session.Resumed, + ID: session.ID, + TenantID: session.TenantID, + OrganizationID: session.OrganizationID, + AcEuiBytes: session.GetAcEuiBytes(), + SnAcUUID: session.SnAcUUID, + SnScUUID: session.SnScUUID, + AcOpIdCounter: session.AcOpIdCounter, + ScOpIdCounter: session.ScOpIdCounter, + Metadata: deepCopyMetadata(session.Metadata), + } + metadataCopy := snap.Metadata go func() { - ctx, cancel := context.WithTimeout(context.Background(), scaci.ConnectPersistTimeout) + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), scaci.ConnectPersistTimeout) defer cancel() - if session.Resumed && session.ID > 0 { + if snap.Resumed && snap.ID > 0 { // Update existing session (resumed connection) now := time.Now() status := "active" @@ -66,14 +97,14 @@ func (p *sessionPersistence) PersistConnectAsync(session *scaci.Session, certFin } // Only persist opId counters if they're non-zero (preserve "opId 0 after connect") - if session.AcOpIdCounter > 0 { - updateReq.LastOpIDAc = &session.AcOpIdCounter + if snap.AcOpIdCounter > 0 { + updateReq.LastOpIDAc = &snap.AcOpIdCounter } - if session.ScOpIdCounter < 0 { - updateReq.LastOpIDSc = &session.ScOpIdCounter + if snap.ScOpIdCounter < 0 { + updateReq.LastOpIDSc = &snap.ScOpIdCounter } - if err := p.sessionRepo.UpdateSession(ctx, session.TenantID, session.ID, updateReq); err != nil { + if err := p.sessionRepo.UpdateSession(ctx, snap.TenantID, snap.ID, updateReq); err != nil { p.logger.ErrorContext(ctx, scaci.LogSCACIUpdateSessionFailed, "error", err) } } else { @@ -98,8 +129,8 @@ func (p *sessionPersistence) PersistConnectAsync(session *scaci.Session, certFin // Set organization ID if not nil var orgID *uuid.UUID - if session.OrganizationID != uuid.Nil { - orgID = &session.OrganizationID + if snap.OrganizationID != uuid.Nil { + orgID = &snap.OrganizationID } // Set negotiated version - defaults to ProtocolVersionString if empty @@ -109,11 +140,11 @@ func (p *sessionPersistence) PersistConnectAsync(session *scaci.Session, certFin } createReq := &models.SCACISessionCreateRequest{ - TenantID: session.TenantID, + TenantID: snap.TenantID, OrganizationID: orgID, - AcEUI: session.GetAcEuiBytes(), - SnAcUUID: session.SnAcUUID, - SnScUUID: session.SnScUUID, + AcEUI: snap.AcEuiBytes, + SnAcUUID: snap.SnAcUUID, + SnScUUID: snap.SnScUUID, CertificateFingerprint: certFP, ClientCertSubject: certSubj, RemoteAddr: remAddr, @@ -125,7 +156,7 @@ func (p *sessionPersistence) PersistConnectAsync(session *scaci.Session, certFin } if dbSession, err := p.sessionRepo.CreateSession(ctx, createReq); err != nil { - p.logger.Error(scaci.LogSCACIPersistSessionFailed, "error", err) + p.logger.ErrorContext(ctx, scaci.LogSCACIPersistSessionFailed, "error", err) } else { // Store DB ID in runtime session // Note: This updates the session object passed by reference diff --git a/KC-Core/internal/services/scaci/session_persistence_test.go b/KC-Core/internal/services/scaci/session_persistence_test.go index 3b1c80c..4e8f7c9 100644 --- a/KC-Core/internal/services/scaci/session_persistence_test.go +++ b/KC-Core/internal/services/scaci/session_persistence_test.go @@ -215,7 +215,7 @@ func TestPersistConnectAsync_FreshSession_WritesOrganizationID(t *testing.T) { } // Call persistence (async) - svc.PersistConnectAsync(session, "cert-fingerprint", "CN=test", "192.168.1.1:5001", + svc.PersistConnectAsync(testutil.TestContext(), session, "cert-fingerprint", "CN=test", "192.168.1.1:5001", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) // Wait for async goroutine to complete (deterministic) @@ -249,7 +249,7 @@ func TestPersistConnectAsync_FreshSession_NilOrgID(t *testing.T) { Resumed: false, } - svc.PersistConnectAsync(session, "", "", "", "", "", scaci.ProtocolVersionString) + svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) mockRepo.waitForCompletion() require.True(t, mockRepo.wasCreateCalled()) @@ -277,7 +277,7 @@ func TestPersistConnectAsync_ResumedSession_PreservesOrgID(t *testing.T) { Resumed: true, // Resumed session } - svc.PersistConnectAsync(session, "", "", "", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) + svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) mockRepo.waitForCompletion() // Assert UpdateSession was called (not CreateSession) @@ -317,7 +317,7 @@ func TestPersistConnectAsync_FreshSession_WritesTLSEvidence(t *testing.T) { tlsVersion := "TLS 1.3" cipherSuite := "TLS_AES_256_GCM_SHA384" - svc.PersistConnectAsync(session, certFingerprint, "CN=test-ac", "10.0.0.1:5001", + svc.PersistConnectAsync(testutil.TestContext(), session, certFingerprint, "CN=test-ac", "10.0.0.1:5001", tlsVersion, cipherSuite, scaci.ProtocolVersionString) mockRepo.waitForCompletion() @@ -359,7 +359,7 @@ func TestPersistConnectAsync_ResumedSession_UpdatesTLSEvidence(t *testing.T) { newTLSVersion := "TLS 1.3" newCipherSuite := "TLS_CHACHA20_POLY1305_SHA256" - svc.PersistConnectAsync(session, "", "", "", newTLSVersion, newCipherSuite, scaci.ProtocolVersionString) + svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", newTLSVersion, newCipherSuite, scaci.ProtocolVersionString) mockRepo.waitForCompletion() require.True(t, mockRepo.wasUpdateCalled()) @@ -402,7 +402,7 @@ func TestPersistConnectAsync_NonBlocking(t *testing.T) { } start := time.Now() - svc.PersistConnectAsync(session, "", "", "", "", "", scaci.ProtocolVersionString) + svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) elapsed := time.Since(start) // Should return almost immediately (not wait for 500ms mock delay) @@ -431,7 +431,7 @@ func TestPersistConnectAsync_NegotiatedVersionDefault(t *testing.T) { } // Pass empty negotiatedVersion - svc.PersistConnectAsync(session, "", "", "", "", "", "") + svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", "") mockRepo.waitForCompletion() require.True(t, mockRepo.wasCreateCalled()) @@ -513,7 +513,7 @@ func TestPersistConnectAsync_FreshSession_WritesNestedMetadata(t *testing.T) { }, } - svc.PersistConnectAsync(session, "", "", "", "", "", scaci.ProtocolVersionString) + svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) mockRepo.waitForCompletion() require.True(t, mockRepo.wasCreateCalled(), "CreateSession should be called for fresh session") @@ -565,7 +565,7 @@ func TestPersistConnectAsync_ResumedSession_WritesNestedMetadata(t *testing.T) { }, } - svc.PersistConnectAsync(session, "", "", "", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) + svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) mockRepo.waitForCompletion() require.True(t, mockRepo.wasUpdateCalled(), "UpdateSession should be called for resumed session") diff --git a/KC-Core/internal/services/scaci/session_validator.go b/KC-Core/internal/services/scaci/session_validator.go index 5277a97..0463130 100644 --- a/KC-Core/internal/services/scaci/session_validator.go +++ b/KC-Core/internal/services/scaci/session_validator.go @@ -41,7 +41,7 @@ func NewSessionValidator() scaci.SessionValidator { // Returns: // - Empty string if validation passes // - Error token string if validation fails (from errors_catalog.go) -func (v *sessionValidator) ValidateConnectFields(req *scaci.Connect, _ int64) string { +func (v *sessionValidator) ValidateConnectFields(req *scaci.Connect) string { // Validation 1: Version must not be empty (§3.3.1-01) if req.Version == "" { return scaci.ErrMissingVersion diff --git a/KC-Core/internal/services/scaci/session_validator_test.go b/KC-Core/internal/services/scaci/session_validator_test.go index 3245a36..17fb2bd 100644 --- a/KC-Core/internal/services/scaci/session_validator_test.go +++ b/KC-Core/internal/services/scaci/session_validator_test.go @@ -20,7 +20,7 @@ func TestValidateConnectFields_ValidFreshSession(t *testing.T) { SnScOpId: nil, } - errToken := validator.ValidateConnectFields(req, 0) + errToken := validator.ValidateConnectFields(req) if errToken != "" { t.Errorf("expected valid fresh session, got error: %s", errToken) } @@ -43,7 +43,7 @@ func TestValidateConnectFields_ValidResumeSession(t *testing.T) { SnScOpId: &scOpId, } - errToken := validator.ValidateConnectFields(req, 0) + errToken := validator.ValidateConnectFields(req) if errToken != "" { t.Errorf("expected valid resume session, got error: %s", errToken) } @@ -61,7 +61,7 @@ func TestValidateConnectFields_MissingVersion(t *testing.T) { }, } - errToken := validator.ValidateConnectFields(req, 0) + errToken := validator.ValidateConnectFields(req) if errToken != "scaci.error.missing_version" { t.Errorf("expected missing_version error, got: %s", errToken) } @@ -79,7 +79,7 @@ func TestValidateConnectFields_ZeroAcEui(t *testing.T) { }, } - errToken := validator.ValidateConnectFields(req, 0) + errToken := validator.ValidateConnectFields(req) if errToken != "scaci.error.missing_ac_eui" { t.Errorf("expected missing_ac_eui error, got: %s", errToken) } @@ -97,7 +97,7 @@ func TestValidateConnectFields_ZeroSnAcUUID(t *testing.T) { }, } - errToken := validator.ValidateConnectFields(req, 0) + errToken := validator.ValidateConnectFields(req) if errToken != "scaci.error.sn_ac_uuid_zero" { t.Errorf("expected sn_ac_uuid_zero error, got: %s", errToken) } @@ -119,7 +119,7 @@ func TestValidateConnectFields_UnpairedResumeFields_AcOpIdOnly(t *testing.T) { SnScOpId: nil, // SnScOpId missing - INVALID } - errToken := validator.ValidateConnectFields(req, 0) + errToken := validator.ValidateConnectFields(req) if errToken != "scaci.error.sn_sc_op_id_required" { t.Errorf("expected sn_sc_op_id_required error, got: %s", errToken) } @@ -141,7 +141,7 @@ func TestValidateConnectFields_UnpairedResumeFields_ScOpIdOnly(t *testing.T) { SnScOpId: &scOpId, // SnScOpId present } - errToken := validator.ValidateConnectFields(req, 0) + errToken := validator.ValidateConnectFields(req) if errToken != "scaci.error.sn_ac_op_id_required" { t.Errorf("expected sn_ac_op_id_required error, got: %s", errToken) } diff --git a/KC-Core/pkg/bssci/attach_detach_profile_test.go b/KC-Core/pkg/bssci/attach_detach_profile_test.go index b327895..b2bbaf5 100644 --- a/KC-Core/pkg/bssci/attach_detach_profile_test.go +++ b/KC-Core/pkg/bssci/attach_detach_profile_test.go @@ -256,7 +256,7 @@ func newProfileTestFixture(t *testing.T, initialProfile string) *profileTestFixt DisableAttachPersistence: true, } server.endpointRepo = repo - server.storage = stubStore + server.SetStorageForTest(stubStore) server.orgResolver = &fakeOrgResolver{ tenantToOrg: make(map[int64]uuid.UUID), orgToTenant: make(map[uuid.UUID]int64), @@ -519,7 +519,7 @@ func TestDetachProfileGuardNonRegression(t *testing.T) { DisableAttachPersistence: true, } server.endpointRepo = repo - server.storage = stubStore + server.SetStorageForTest(stubStore) server.orgResolver = &fakeOrgResolver{ tenantToOrg: make(map[int64]uuid.UUID), orgToTenant: make(map[uuid.UUID]int64), diff --git a/KC-Core/pkg/bssci/attach_propagate_integration_test.go b/KC-Core/pkg/bssci/attach_propagate_integration_test.go index 4d04b8d..8226f95 100644 --- a/KC-Core/pkg/bssci/attach_propagate_integration_test.go +++ b/KC-Core/pkg/bssci/attach_propagate_integration_test.go @@ -862,9 +862,9 @@ func TestHandleAttachPropagateResponse_Rejected(t *testing.T) { "wire message must be cataloged via ErrAttachPropagateFailed") // Pending-op metadata persistence. - update := pendingOps.LastUpdate() - require.NotNil(t, update, "UpdateMetadata must be called once on the rejected path") - require.Equal(t, 1, pendingOps.UpdateCalls(), "exactly one UpdateMetadata call") + updates := bssci.StatusMetadataUpdates(statusSvc) + require.Len(t, updates, 1, "exactly one metadata persistence call on the rejected path") + update := updates[0] var metadata map[string]interface{} require.NoError(t, json.Unmarshal(update.Metadata, &metadata)) assert.Equal(t, true, metadata["failed"], "metadata.failed must be true") diff --git a/KC-Core/pkg/bssci/attach_replay_protection_test.go b/KC-Core/pkg/bssci/attach_replay_protection_test.go index 8519e5f..c208a00 100644 --- a/KC-Core/pkg/bssci/attach_replay_protection_test.go +++ b/KC-Core/pkg/bssci/attach_replay_protection_test.go @@ -91,7 +91,7 @@ func TestAttachReplayProtection_RejectReplay(t *testing.T) { DisableAttachPersistence: true, } server.endpointRepo = newFakeEndpointRepo(endpoint) - server.storage = storage + server.SetStorageForTest(storage) server.orgResolver = &fakeOrgResolver{ tenantToOrg: make(map[int64]uuid.UUID), orgToTenant: make(map[uuid.UUID]int64), @@ -212,7 +212,7 @@ func TestAttachReplayProtection_RejectLowerCounter(t *testing.T) { ) server.config = &Config{MessageEncoding: EncodingJSON, DisableAttachPersistence: true} server.endpointRepo = newFakeEndpointRepo(endpoint) - server.storage = storage + server.SetStorageForTest(storage) server.orgResolver = &fakeOrgResolver{ tenantToOrg: make(map[int64]uuid.UUID), orgToTenant: make(map[uuid.UUID]int64), @@ -310,7 +310,7 @@ func TestAttachReplayProtection_RolloverEdgeCase(t *testing.T) { ) server.config = &Config{MessageEncoding: EncodingJSON, DisableAttachPersistence: true} server.endpointRepo = newFakeEndpointRepo(endpoint) - server.storage = storage + server.SetStorageForTest(storage) server.orgResolver = &fakeOrgResolver{ tenantToOrg: make(map[int64]uuid.UUID), orgToTenant: make(map[uuid.UUID]int64), @@ -403,7 +403,7 @@ func TestAttachReplayProtection_FirstAttachNilCounter(t *testing.T) { ) server.config = &Config{MessageEncoding: EncodingJSON, DisableAttachPersistence: true} server.endpointRepo = newFakeEndpointRepo(endpoint) - server.storage = storage + server.SetStorageForTest(storage) server.orgResolver = &fakeOrgResolver{ tenantToOrg: make(map[int64]uuid.UUID), orgToTenant: make(map[uuid.UUID]int64), diff --git a/KC-Core/pkg/bssci/connect_failure_test.go b/KC-Core/pkg/bssci/connect_failure_test.go index a74c907..464015d 100644 --- a/KC-Core/pkg/bssci/connect_failure_test.go +++ b/KC-Core/pkg/bssci/connect_failure_test.go @@ -23,6 +23,12 @@ func (failingRegistrationConnSvc) RegisterConnection(_ context.Context, _ *Sessi return errors.New("registration failed") } +func (failingRegistrationConnSvc) DisconnectBaseStationIfCurrent(_ context.Context, _ [8]byte, _ string) error { + return nil +} + +func (failingRegistrationConnSvc) UpdateLastSeen(_ context.Context, _ [8]byte) error { return nil } + // terminateSpySessionSvc records TerminateSession calls to prove the // activation compensation runs. type terminateSpySessionSvc struct { diff --git a/KC-Core/pkg/bssci/connect_handler_test.go b/KC-Core/pkg/bssci/connect_handler_test.go index a3794c7..02b5645 100644 --- a/KC-Core/pkg/bssci/connect_handler_test.go +++ b/KC-Core/pkg/bssci/connect_handler_test.go @@ -54,6 +54,12 @@ func (m *mockConnectionService) RegisterConnection(ctx context.Context, _ *bssci return nil } +func (m *mockConnectionService) DisconnectBaseStationIfCurrent(_ context.Context, _ [8]byte, _ string) error { + return nil +} + +func (m *mockConnectionService) UpdateLastSeen(_ context.Context, _ [8]byte) error { return nil } + // mockConnForConnect implements net.Conn to capture error messages sent during connect type mockConnForConnect struct { errorSent bool diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index 8062a4f..77546eb 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -4,9 +4,12 @@ import ( "context" "crypto/x509" "encoding/json" + "time" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/blueprint" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" "github.com/google/uuid" @@ -152,6 +155,33 @@ type StatusService interface { // Returns tenantID for proper roaming tenant isolation (BSSCI §5.12). // Session parameter required for SessionOpKey composite key lookup. ExtractQueueMetadata(session *Session, opId int64) (endpointEUI uint64, queueID int64, tenantID string) + + // UpdatePendingOperationMetadata persists new metadata for an existing + // pending row (metadataJSON is the pre-marshaled form of metadata) and, + // on success, mirrors it into the cached operation. The DB write comes + // first so the cache never runs ahead of the durable state. + UpdatePendingOperationMetadata(ctx context.Context, session *Session, opId int64, metadata map[string]interface{}, metadataJSON json.RawMessage) error + + // PersistedOperations returns the raw persisted rows for session resume + // hydration; strict decoding stays with the caller. + PersistedOperations(ctx context.Context, sessionID int64) ([]PersistedOperation, error) + + // DeletePendingOperations removes every persisted row of the session and, + // only after the DB deletion succeeds, evicts the session's cached + // operations. A failed deletion leaves the cache untouched. + DeletePendingOperations(ctx context.Context, session *Session) (int64, error) +} + +// PersistedOperation is a raw persisted pending-operation row returned for +// resume hydration. It is BSSCI-owned so the storage row model does not leak +// through the service boundary; payloads stay encoded. +type PersistedOperation struct { + OperationID int64 + OperationType string + EndpointEUI []byte + OperationData []byte + Metadata []byte + CreatedAt time.Time } // BaseStationConnectionRegistry owns the live-connection operations the @@ -166,6 +196,14 @@ type BaseStationConnectionRegistry interface { // RegisterConnection publishes the session's live connection and marks the // base station online. RegisterConnection(ctx context.Context, session *Session, baseStation *basestation.BaseStation) error + + // DisconnectBaseStationIfCurrent marks the base station offline only while + // the given connection is still its current one (a reconnect that already + // replaced this connection keeps the station online). + DisconnectBaseStationIfCurrent(ctx context.Context, eui [8]byte, connectionID string) error + + // UpdateLastSeen refreshes the base station's last-seen timestamp. + UpdateLastSeen(ctx context.Context, eui [8]byte) error } // CertificateIdentity is the tenant/organization identity resolved from a @@ -877,3 +915,114 @@ type BlueprintResolver interface { GetEndpointCalibration(ctx context.Context, tenantID int64, endpoint *models.EndPoint) map[string]interface{} } + +// ProtocolMessageStore records BSSCI protocol message rows (detach and +// propagate messages). Satisfied structurally by the MIOTY message repository. +type ProtocolMessageStore interface { + CreateDetachMessage(ctx context.Context, msg *mioty.DetachMessage, structuredMsg map[string]interface{}) error + CreateAttachPropagateMessage(ctx context.Context, msg *mioty.AttachPropagateMessage) error + CreateDetachPropagateMessage(ctx context.Context, msg *mioty.DetachPropagateMessage) error +} + +// DLRXStatusStore owns dlRxStatQry correlation rows and dlRxStat reports +// (BSSCI rev1 §5.15-§5.16 / classic §3.15-§3.16). Satisfied structurally by +// the DL RX status repository. +type DLRXStatusStore interface { + CreateDLRXStatus(ctx context.Context, status *mioty.DLRXStatus) error + MarkDLRXStatusReceived(ctx context.Context, tenantID int64, epEui, bsEui []byte, bsOpID int64) (bool, error) + CreateDLRXStatusQuery(ctx context.Context, tenantID int64, orgUUID *uuid.UUID, epEui, bsEui []byte, opId int64) error + ExpireDLRXStatusQuery(ctx context.Context, cutoff time.Time) (int64, error) +} + +// BaseStationStatusStore records base station status history rows (BSSCI +// rev1 §5.5 / classic §3.5). Satisfied structurally by the status repository. +type BaseStationStatusStore interface { + Create(ctx context.Context, status *mioty.BaseStationStatusRecord) error +} + +// DownlinkQueueStore covers the queue-row operations the protocol handlers +// perform outside the dispatcher's reservation flow. Satisfied structurally +// by the MIOTY downlink repository so error identity (sql.ErrNoRows, +// storage.ErrNotFound) is preserved - never wrap it in a delegating adapter. +type DownlinkQueueStore interface { + UpdateDownlinkBaseStation(ctx context.Context, queId uint64, tenantID string, bsEUI uint64) error + MarkReservedAsQueued(ctx context.Context, queID uint64, tenantID int64, bsEUI uint64, txTime int64, packetCnt *uint32, orgID *uuid.UUID) error + GetDownlinkByQueueID(ctx context.Context, queId uint64, tenantID string) (*storage.DownlinkMessage, error) +} + +// EndpointDirectory is the endpoint repository surface the protocol server +// consumes: reads plus the attach/detach field updates. Satisfied +// structurally by the endpoint repository. +type EndpointDirectory interface { + Get(ctx context.Context, eui models.EUI) (*models.EndPoint, error) + GetByEUI(ctx context.Context, tenantID int64, eui []byte) (*models.EndPoint, error) + GetByID(ctx context.Context, id int64, tenantID int64) (*models.EndPoint, error) + UpdateFields(ctx context.Context, tenantID int64, endpointID int64, updates map[string]interface{}) error + UpdateRadioMetricsSelective(ctx context.Context, tenantID int64, eui models.EUI, update interfaces.RadioMetricsUpdate) error +} + +// BaseStationStore is the registered-station repository surface the protocol +// server consumes. Satisfied structurally by the base station repository. +type BaseStationStore interface { + GetByEUI(ctx context.Context, tenantID int64, eui []byte) (*models.BaseStation, error) + Update(ctx context.Context, tenantID, id int64, updates map[string]interface{}) error +} + +// OrganizationDirectory resolves organization identity for tenants and +// certificates. Satisfied structurally by org.Resolver implementations. +type OrganizationDirectory interface { + ResolveCert(ctx context.Context, cert *x509.Certificate) (uuid.UUID, int64, error) + GetDefaultOrgForTenant(ctx context.Context, tenantID int64) (uuid.UUID, error) +} + +// NetworkKeyProtector encrypts and decrypts endpoint network session keys. +// Satisfied structurally by *crypto.KeyEncryptor. +type NetworkKeyProtector interface { + EncryptKey(plaintext []byte) (string, error) + EncryptKeyRaw(plaintext []byte) ([]byte, error) + DecryptKeyRaw(ciphertext []byte) ([]byte, error) +} + +// EventStore records system events emitted by the protocol server. +// Satisfied structurally by the system event repository. +type EventStore interface { + CreateEvent(ctx context.Context, event *models.SystemEvent) error +} + +// AttachSessionRecord carries the attach-transaction inputs for the endpoint +// attachment persister (BSSCI rev1 §5.7 / classic §3.7). +type AttachSessionRecord struct { + // TenantID is the endpoint owner tenant used for the endpoint update and + // the session row. + TenantID int64 + // BSLookupTenantID scopes the primary base station lookup (differs from + // TenantID when the uplink arrived through a roaming station). + BSLookupTenantID int64 + EndpointID int64 + EndpointUpdates map[string]interface{} + EncryptedKey []byte + // AttachCnt is handler-validated to fit 24 bits before persistence. + AttachCnt uint32 + ShAddr uint16 + BaseStationEUI []byte +} + +// AttachPropagateSessionRecord carries the attach-propagate transaction inputs +// (BSSCI rev1 §5.8 / classic §3.8); the owner tenant scopes every operation. +type AttachPropagateSessionRecord struct { + TenantID int64 + EndpointID int64 + EndpointUpdates map[string]interface{} + EncryptedKey []byte + ShAddr uint16 + BaseStationEUI []byte +} + +// EndpointAttachmentPersistence owns the transactional attach and +// attach-propagate endpoint-session persistence: one transaction covering the +// endpoint field update and the endpoint-session upsert, with the primary +// base station looked up outside the transaction. +type EndpointAttachmentPersistence interface { + PersistAttachSession(ctx context.Context, rec AttachSessionRecord) error + PersistAttachPropagateSession(ctx context.Context, rec AttachPropagateSessionRecord) error +} diff --git a/KC-Core/pkg/bssci/detach_integration_test.go b/KC-Core/pkg/bssci/detach_integration_test.go index 6226470..88dfe08 100644 --- a/KC-Core/pkg/bssci/detach_integration_test.go +++ b/KC-Core/pkg/bssci/detach_integration_test.go @@ -208,7 +208,7 @@ func newDetachTestEnv(t *testing.T, cfg *Config, endpoint *models.EndPoint) *det ) server.config = cfg server.endpointRepo = newFakeEndpointRepo(endpoint) - server.storage = storage + server.SetStorageForTest(storage) server.orgResolver = &fakeOrgResolver{ tenantToOrg: make(map[int64]uuid.UUID), orgToTenant: make(map[uuid.UUID]int64), @@ -828,7 +828,7 @@ func TestDetachCrossTenantContextIsolation(t *testing.T) { require.NoError(t, env.server.handleDetach(env.server, env.session, msg, payload)) // FIX-1: Verify message stored with endpoint owner tenant using Background context - msgRepo := env.server.storage.MIOTYMessages().(*stubMIOTYMessageRepo) + msgRepo := env.server.protocolMessages.(*stubMIOTYMessageRepo) msgRepo.mu.Lock() require.Len(t, msgRepo.detachs, 1, "detach message must be persisted") storedMsg := msgRepo.detachs[0] @@ -870,7 +870,7 @@ func TestDetachUnknownEndpointMetadataPersistence(t *testing.T) { "unknown endpoint detach must return detRsp, commands seen: %#v", commandsSnapshot) // FIX-3: Verify message persisted with session tenant - msgRepo := env.server.storage.MIOTYMessages().(*stubMIOTYMessageRepo) + msgRepo := env.server.protocolMessages.(*stubMIOTYMessageRepo) msgRepo.mu.Lock() require.Len(t, msgRepo.detachs, 1, "detach message must be persisted for unknown endpoint") storedMsg := msgRepo.detachs[0] @@ -962,7 +962,7 @@ func TestDetachOrgResolverFailureFallback(t *testing.T) { require.NoError(t, env.server.handleDetach(env.server, env.session, msg, payload)) // Verify message persisted with tenant but nil org UUID - msgRepo := env.server.storage.MIOTYMessages().(*stubMIOTYMessageRepo) + msgRepo := env.server.protocolMessages.(*stubMIOTYMessageRepo) msgRepo.mu.Lock() require.Len(t, msgRepo.detachs, 1) storedMsg := msgRepo.detachs[0] @@ -1041,7 +1041,7 @@ func TestSendDetachPropagatePersistence(t *testing.T) { require.NoError(t, err, "SendDetachPropagate should succeed") // Verify detach propagate message was persisted - msgRepo := env.server.storage.MIOTYMessages().(*stubMIOTYMessageRepo) + msgRepo := env.server.protocolMessages.(*stubMIOTYMessageRepo) msgRepo.mu.Lock() require.Len(t, msgRepo.detachPropagates, 1, "detach propagate message must be persisted") storedMsg := msgRepo.detachPropagates[0] @@ -1103,7 +1103,7 @@ func TestSendDetachPropagateUnknownEndpoint(t *testing.T) { require.NoError(t, err, "SendDetachPropagate should succeed for unknown endpoint") // Verify detach propagate message was persisted - msgRepo := env.server.storage.MIOTYMessages().(*stubMIOTYMessageRepo) + msgRepo := env.server.protocolMessages.(*stubMIOTYMessageRepo) msgRepo.mu.Lock() require.Len(t, msgRepo.detachPropagates, 1, "detach propagate message must be persisted for unknown endpoint") storedMsg := msgRepo.detachPropagates[0] diff --git a/KC-Core/pkg/bssci/detach_propagate_complete_test.go b/KC-Core/pkg/bssci/detach_propagate_complete_test.go index 9ed4a64..71f8f1f 100644 --- a/KC-Core/pkg/bssci/detach_propagate_complete_test.go +++ b/KC-Core/pkg/bssci/detach_propagate_complete_test.go @@ -732,9 +732,9 @@ func TestHandleDetachPropagateResponse_Rejected(t *testing.T) { "wire message must be cataloged via ErrDetachPropagateFailed") // Pending-op metadata persistence. - update := pendingOps.LastUpdate() - require.NotNil(t, update, "UpdateMetadata must be called once on the rejected path") - require.Equal(t, 1, pendingOps.UpdateCalls(), "exactly one UpdateMetadata call") + updates := bssci.StatusMetadataUpdates(statusSvc) + require.Len(t, updates, 1, "exactly one metadata persistence call on the rejected path") + update := updates[0] var metadata map[string]interface{} require.NoError(t, json.Unmarshal(update.Metadata, &metadata)) assert.Equal(t, true, metadata["failed"], "metadata.failed must be true") diff --git a/KC-Core/pkg/bssci/disposition.go b/KC-Core/pkg/bssci/disposition.go deleted file mode 100644 index 0854537..0000000 --- a/KC-Core/pkg/bssci/disposition.go +++ /dev/null @@ -1,44 +0,0 @@ -package bssci - -import ( - "context" - "encoding/binary" - "errors" - - "github.com/Kiloiot/kilo-service-center/KC-DB/storage" - "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" - "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" -) - -// LocalEndpointDispositionResolver classifies uplinks using the local endpoint repository. -// Endpoints found in any local tenant are served locally; unknown endpoints are dropped. -// CE mode replaces this with a relay-capable resolver in internal/services/federation/disposition.go. -type LocalEndpointDispositionResolver struct { - endpointRepo interfaces.EndpointRepository -} - -// NewLocalEndpointDispositionResolver creates a resolver that classifies endpoints as -// Local (found in any tenant) or Drop (unknown, no relay configured). -func NewLocalEndpointDispositionResolver(repo interfaces.EndpointRepository) *LocalEndpointDispositionResolver { - return &LocalEndpointDispositionResolver{endpointRepo: repo} -} - -// Resolve checks the local endpoint repository for the given EUI. -// Returns DispositionLocal if found in any tenant, DispositionDrop if not found, -// or an error if the repository query itself fails. -func (r *LocalEndpointDispositionResolver) Resolve(ctx context.Context, epEUI uint64) (IngressDisposition, error) { - euiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(euiBytes, epEUI) - - var eui models.EUI - copy(eui[:], euiBytes) - - _, err := r.endpointRepo.Get(ctx, eui) - if err != nil { - if errors.Is(err, storage.ErrNotFound) { - return DispositionDrop, nil - } - return DispositionDrop, err - } - return DispositionLocal, nil -} diff --git a/KC-Core/pkg/bssci/dl_rx_status_test.go b/KC-Core/pkg/bssci/dl_rx_status_test.go index b354d48..e0e8190 100644 --- a/KC-Core/pkg/bssci/dl_rx_status_test.go +++ b/KC-Core/pkg/bssci/dl_rx_status_test.go @@ -165,8 +165,19 @@ func TestHandlerRegistration(t *testing.T) { sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(logger, nil) versionNegotiator, err := bssciservices.NewVersionNegotiator([]string{mioty.MIOTYProtocolVersion}, logger) require.NoError(t, err, "NewVersionNegotiator should build from the canonical version") - server, err := bssci.NewServer(&bssci.Config{}, logger, nil, nil, nil, nil, nil, 1, - sessionSvc, versionNegotiator, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, nil, 1) + server, err := bssci.NewServer(&bssci.Config{}, logger, bssci.Dependencies{ + SessionSvc: sessionSvc, + VersionNegotiator: versionNegotiator, + DownlinkSvc: downlinkSvc, + StatusSvc: statusSvc, + ConnectionRegistry: connectionSvc, + Broadcaster: broadcaster, + QueueSerializer: queueSerializer, + AuditLogger: auditLogger, + TenantResolver: tenantResolver, + TenantID: 1, + DefaultTenantID: 1, + }) require.NoError(t, err, "NewServer should not return error with valid StatusService") server.RegisterHandlers() diff --git a/KC-Core/pkg/bssci/downlink_handlers.go b/KC-Core/pkg/bssci/downlink_handlers.go index 91f8de3..750ff6c 100644 --- a/KC-Core/pkg/bssci/downlink_handlers.go +++ b/KC-Core/pkg/bssci/downlink_handlers.go @@ -59,7 +59,7 @@ func (s *Server) handleDLDataQueueResponse(_ *Server, session *Session, msg *Mes endpointEUI, queueID, tenantIDStr := s.statusSvc.ExtractQueueMetadata(session, msg.OpId) // Update downlink base station ownership in database - if s.storage != nil && queueID > 0 { + if s.downlinkQueueStore != nil && queueID > 0 { ctx := s.sessionContext(session) // Use tenant ID from queue metadata per BSSCI §5.12 (roaming) // Fallback to server default only if metadata extraction failed @@ -67,7 +67,7 @@ func (s *Server) handleDLDataQueueResponse(_ *Server, session *Session, msg *Mes tenantIDStr = s.formatTenantID() } //nolint:gosec // G115: queueID > 0 guard ensures safe int64->uint64 conversion - if err := s.storage.MIOTYDownlinks().UpdateDownlinkBaseStation(ctx, uint64(queueID), tenantIDStr, session.BaseStationEUI); err != nil { + if err := s.downlinkQueueStore.UpdateDownlinkBaseStation(ctx, uint64(queueID), tenantIDStr, session.BaseStationEUI); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToUpdateDownlinkBaseStationOwnership, //nolint:gosec // G115: queueID > 0 guard ensures safe int64->uint64 conversion "queId", uint64(queueID), @@ -81,7 +81,7 @@ func (s *Server) handleDLDataQueueResponse(_ *Server, session *Session, msg *Mes // this repairs the row. An already-queued row is a no-op. if tenantID, parseErr := strconv.ParseInt(tenantIDStr, 10, 64); parseErr == nil { //nolint:gosec // G115: queueID > 0 guard ensures safe int64->uint64 conversion - if err := s.storage.MIOTYDownlinks().MarkReservedAsQueued(ctx, uint64(queueID), tenantID, + if err := s.downlinkQueueStore.MarkReservedAsQueued(ctx, uint64(queueID), tenantID, session.BaseStationEUI, time.Now().UnixNano(), nil, nil); err != nil { s.logger.WarnContext(ctx, LogBSSCIFailedToConfirmDownlinkQueued, //nolint:gosec // G115: queueID > 0 guard ensures safe int64->uint64 conversion @@ -1126,35 +1126,31 @@ func (s *Server) handleDLRXStatus(_ *Server, session *Session, msg *Message, dat binary.BigEndian.PutUint64(bsEuiBytes, session.BaseStationEUI) // Check if this dlRxStat corresponds to a pending query (BSSCI §5.15 correlation) - if s.storage != nil { - dlRxStatusRepo := s.storage.DLRXStatus() - if dlRxStatusRepo != nil { - // Correlate by tenant+endpoint only (oldest pending) per BSSCI §5.15 - // BS opId stored for audit but NOT used in WHERE clause (different namespace) - found, err := dlRxStatusRepo.MarkDLRXStatusReceived( - ownerCtx, - tenantID, - epEuiBytes, // Match by tenant + endpoint only - bsEuiBytes, // Audit: which BS actually reported - msg.OpId, // Audit: BS opId (different namespace) - ) - if err != nil { - s.logger.ErrorContext(ownerCtx, LogBSSCIFailedToCorrelateDLRXQuery, - "epEui", dlRxStatus.EpEui, - "opId", msg.OpId, - "error", err) - // Non-fatal: Continue processing even if correlation check fails - } + if s.dlrxStore != nil { + // Correlate by tenant+endpoint only (oldest pending) per BSSCI §5.15 + // BS opId stored for audit but NOT used in WHERE clause (different namespace) + found, err := s.dlrxStore.MarkDLRXStatusReceived( + ownerCtx, + tenantID, + epEuiBytes, // Match by tenant + endpoint only + bsEuiBytes, // Audit: which BS actually reported + msg.OpId, // Audit: BS opId (different namespace) + ) + if err != nil { + s.logger.ErrorContext(ownerCtx, LogBSSCIFailedToCorrelateDLRXQuery, + "epEui", dlRxStatus.EpEui, + "opId", msg.OpId, + "error", err) + // Non-fatal: Continue processing even if correlation check fails + } - if !found { - s.logger.WarnContext(ownerCtx, LogBSSCIUnsolicitedDLRXStatus, - "epEui", dlRxStatus.EpEui, - "tenant", tenantID, - "bsEui", session.BaseStationEUI) - // Log-only mode: continue processing unsolicited reports - } + if !found { + s.logger.WarnContext(ownerCtx, LogBSSCIUnsolicitedDLRXStatus, + "epEui", dlRxStatus.EpEui, + "tenant", tenantID, + "bsEui", session.BaseStationEUI) + // Log-only mode: continue processing unsolicited reports } - // Repository unavailable is non-fatal - continue processing } // Resolve endpoint owner's organization UUID (BSSCI §5.15) @@ -1323,7 +1319,7 @@ func (s *Server) SendDLRXStatusQuery(sessionID string, epEui uint64) error { // the eventual dlRxStat report could not be attributed. Owner-organization // resolution failure alone stays non-fatal (nil org, backward compatible). func (s *Server) persistDLRXQueryCorrelation(session *Session, opId int64, epEui uint64, euiBytes []byte) error { - if s.storage == nil || s.storage.DLRXStatus() == nil { + if s.dlrxStore == nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLRxStatQueryTracking, "sessionID", session.DbSessionID, "opId", opId, @@ -1346,7 +1342,7 @@ func (s *Server) persistDLRXQueryCorrelation(session *Session, opId int64, epEui bsEuiBytes := make([]byte, 8) binary.BigEndian.PutUint64(bsEuiBytes, session.BaseStationEUI) - if err := s.storage.DLRXStatus().CreateDLRXStatusQuery(s.sessionContext(session), tenantID, epOwnerOrgUUID, euiBytes, bsEuiBytes, opId); err != nil { + if err := s.dlrxStore.CreateDLRXStatusQuery(s.sessionContext(session), tenantID, epOwnerOrgUUID, euiBytes, bsEuiBytes, opId); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistDLRxStatQueryTracking, "sessionID", session.DbSessionID, "opId", opId, @@ -1395,11 +1391,11 @@ func (s *Server) resolveOwnerOrgUUID(ctx context.Context, tenantID int64, epEuiB } // Fetch endpoint to verify it exists and belongs to this tenant - if s.storage == nil || s.storage.EndPoints() == nil { + if s.endpointRepo == nil { return nil, fmt.Errorf("endpoint repository not available") } - endpoint, err := s.storage.EndPoints().GetByEUI(ctx, tenantID, epEuiBytes) + endpoint, err := s.endpointRepo.GetByEUI(ctx, tenantID, epEuiBytes) if err != nil { return nil, fmt.Errorf("failed to fetch endpoint: %w", err) } @@ -1441,24 +1437,18 @@ func (s *Server) persistDLRXStatus(ctx context.Context, tenantID int64, ownerOrg DlRxRssi: dlRxRssi, } - // Use repository through storage interface with session context - if s.storage != nil { - dlRxStatusRepo := s.storage.DLRXStatus() - if dlRxStatusRepo != nil { - if err := dlRxStatusRepo.CreateDLRXStatus(ctx, status); err != nil { - return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistDLRXStatus), err) - } - - s.logger.DebugContext(ctx, LogBSSCIPersistedDLRxStatus, - "epEui", epEui, - "rxTime", rxTime, - "dlRxSnr", dlRxSnr, - "dlRxRssi", dlRxRssi, - "tenantID", tenantID) - } else { - // Repository accessor returned nil - cannot persist - return fmt.Errorf("%s: DL RX status repository not available", ResolveErrorMessage(errFailedToPersistDLRXStatus)) + // Persist through the DL RX status store with session context + if s.dlrxStore != nil { + if err := s.dlrxStore.CreateDLRXStatus(ctx, status); err != nil { + return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToPersistDLRXStatus), err) } + + s.logger.DebugContext(ctx, LogBSSCIPersistedDLRxStatus, + "epEui", epEui, + "rxTime", rxTime, + "dlRxSnr", dlRxSnr, + "dlRxRssi", dlRxRssi, + "tenantID", tenantID) } else { s.logger.WarnContext(ctx, LogBSSCIMessageStoreNotAvailableForDLRxStatus, "epEui", epEui, @@ -1556,9 +1546,9 @@ func (s *Server) RevokeDownlink(tenantID int64, queId uint64) (bsEui uint64, err var foundSession *Session // Query database for queue entry - if s.storage != nil { + if s.downlinkQueueStore != nil { // Use GetDownlinkByQueueID to find the queue entry - downlink, err := s.storage.MIOTYDownlinks().GetDownlinkByQueueID(ctx, queId, tenantIDStr) + downlink, err := s.downlinkQueueStore.GetDownlinkByQueueID(ctx, queId, tenantIDStr) if err != nil { if errors.Is(err, sql.ErrNoRows) || errors.Is(err, storage.ErrNotFound) { // Map to scheduler contract sentinel diff --git a/KC-Core/pkg/bssci/mandatory_field_validation_test.go b/KC-Core/pkg/bssci/mandatory_field_validation_test.go index 8a50ce4..ca6e91a 100644 --- a/KC-Core/pkg/bssci/mandatory_field_validation_test.go +++ b/KC-Core/pkg/bssci/mandatory_field_validation_test.go @@ -1273,7 +1273,7 @@ func TestAttachNonceValidation(t *testing.T) { server.config = &Config{ MessageEncoding: EncodingJSON, } - server.storage = storage + server.SetStorageForTest(storage) // Seed endpoint so valid cases proceed past lookup and signature check endpoint := buildTestEndpointForValidation() @@ -1366,7 +1366,7 @@ func TestAttachSignValidation(t *testing.T) { server.config = &Config{ MessageEncoding: EncodingJSON, } - server.storage = storage + server.SetStorageForTest(storage) // Seed endpoint for lookup endpoint := buildTestEndpointForValidation() @@ -1463,7 +1463,7 @@ func TestAttachCounterRange(t *testing.T) { server.config = &Config{ MessageEncoding: EncodingJSON, } - server.storage = storage + server.SetStorageForTest(storage) // Seed endpoint for lookup endpoint := buildTestEndpointForValidation() diff --git a/KC-Core/pkg/bssci/mqtt_publish_test.go b/KC-Core/pkg/bssci/mqtt_publish_test.go index cfd31d8..b770410 100644 --- a/KC-Core/pkg/bssci/mqtt_publish_test.go +++ b/KC-Core/pkg/bssci/mqtt_publish_test.go @@ -26,6 +26,35 @@ type mockMQTTEventPublisher struct { detaches []mockDetachCall dlResults []mockDLResultCall returnErr error + published chan struct{} +} + +// signalPublish notifies a waiting assertNoPublish; safe when no channel is set. +func (m *mockMQTTEventPublisher) signalPublish() { + if m.published != nil { + select { + case m.published <- struct{}{}: + default: + } + } +} + +// assertNoPublish fails the test when any publish lands within the window. +// The publishes under test run in goroutines, so an immediate counter check +// can pass before the goroutine executes; waiting on the signal channel makes +// the negative assertion deterministic. +func (m *mockMQTTEventPublisher) assertNoPublish(t *testing.T) { + t.Helper() + select { + case <-m.published: + t.Fatal("unexpected MQTT publish for unresolved organization") + case <-time.After(200 * time.Millisecond): + } +} + +// newSilentMockMQTTEventPublisher builds a mock armed for assertNoPublish. +func newSilentMockMQTTEventPublisher() *mockMQTTEventPublisher { + return &mockMQTTEventPublisher{published: make(chan struct{}, 16)} } type mockUplinkCall struct { @@ -66,6 +95,7 @@ func (m *mockMQTTEventPublisher) PublishUplink(_ context.Context, orgUUID string OrgUUID: orgUUID, EpEUI: epEUI, BsEUI: bsEUI, Rssi: rssi, Snr: snr, RxTime: rxTime, PacketCnt: packetCnt, UserData: userData, }) + m.signalPublish() return m.returnErr } @@ -73,6 +103,7 @@ func (m *mockMQTTEventPublisher) PublishAttach(_ context.Context, orgUUID string m.mu.Lock() defer m.mu.Unlock() m.attaches = append(m.attaches, mockAttachCall{OrgUUID: orgUUID, EpEUI: epEUI, BsEUI: bsEUI}) + m.signalPublish() return m.returnErr } @@ -80,6 +111,7 @@ func (m *mockMQTTEventPublisher) PublishDetach(_ context.Context, orgUUID string m.mu.Lock() defer m.mu.Unlock() m.detaches = append(m.detaches, mockDetachCall{OrgUUID: orgUUID, EpEUI: epEUI, BsEUI: bsEUI}) + m.signalPublish() return m.returnErr } @@ -87,6 +119,7 @@ func (m *mockMQTTEventPublisher) PublishDownlinkResult(_ context.Context, orgUUI m.mu.Lock() defer m.mu.Unlock() m.dlResults = append(m.dlResults, mockDLResultCall{OrgUUID: orgUUID, EpEUI: epEUI, QueID: queID, Result: result}) + m.signalPublish() return m.returnErr } @@ -357,7 +390,7 @@ func TestHandleAttachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { testLogger := logger.NewNop() server := NewTestServerWithMemoryStatusService(testLogger, nil, nil, 42) - mock := &mockMQTTEventPublisher{} + mock := newSilentMockMQTTEventPublisher() server.SetMQTTPublisher(mock) // orgResolver returns uuid.Nil for unknown tenants server.orgResolver = &mqttTestOrgResolver{ @@ -389,7 +422,8 @@ func TestHandleAttachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { msg := &Message{Command: mioty.CmdAttachComplete, OpId: 1002} _ = server.CallHandleAttachComplete(session, msg, nil) - // Publish should be skipped (org unresolved → uuid.Nil → empty string) + // Publish must be skipped (org unresolved → uuid.Nil) + mock.assertNoPublish(t) assert.Equal(t, 0, mock.attachCount()) } @@ -448,7 +482,7 @@ func TestHandleDetachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { testLogger := logger.NewNop() server := NewTestServerWithMemoryStatusService(testLogger, nil, nil, 42) - mock := &mockMQTTEventPublisher{} + mock := newSilentMockMQTTEventPublisher() server.SetMQTTPublisher(mock) session := &Session{ @@ -476,6 +510,7 @@ func TestHandleDetachComplete_OrgUnresolved_SkipsPublish(t *testing.T) { msg := &Message{Command: mioty.CmdDetachComplete, OpId: 2002} _ = server.CallHandleDetachComplete(session, msg, nil) + mock.assertNoPublish(t) assert.Equal(t, 0, mock.detachCount()) } @@ -512,7 +547,6 @@ func TestHandleULData_PublishesMQTTUplink(t *testing.T) { // Wire deduplicator (required by handleULData) dedup := NewMessageDeduplicator(5 * time.Minute) defer dedup.Stop() - server.deduplicator = dedup server.uplinkIngestSvc = &mqttPublishingIngestService{server: server} // Wire org resolver to map server tenant → known org UUID @@ -581,15 +615,13 @@ func TestHandleULData_OrgUnresolved_SkipsPublish(t *testing.T) { dedup := NewMessageDeduplicator(5 * time.Minute) defer dedup.Stop() server.uplinkIngestSvc = &mqttPublishingIngestService{server: server} - server.deduplicator = dedup // orgResolver returns uuid.Nil for server tenant (no org mapping) server.orgResolver = &mqttTestOrgResolver{ tenantToOrg: map[int64]uuid.UUID{}, } - // Wire non-sync mock (no WaitGroup since publish should be skipped) - mock := &mockMQTTEventPublisher{} + mock := newSilentMockMQTTEventPublisher() server.SetMQTTPublisher(mock) session := &Session{ @@ -615,7 +647,8 @@ func TestHandleULData_OrgUnresolved_SkipsPublish(t *testing.T) { msg := &Message{Command: mioty.CmdULData, OpId: 5002} _ = server.CallHandleULData(session, msg, data) - // Publish should be skipped (org unresolved → uuid.Nil → empty string) + // Publish must be skipped (org unresolved → uuid.Nil) + mock.assertNoPublish(t) assert.Equal(t, 0, mock.uplinkCount()) } diff --git a/KC-Core/pkg/bssci/pending_op_leak_test.go b/KC-Core/pkg/bssci/pending_op_leak_test.go index 09b79fe..4a95700 100644 --- a/KC-Core/pkg/bssci/pending_op_leak_test.go +++ b/KC-Core/pkg/bssci/pending_op_leak_test.go @@ -24,7 +24,7 @@ func TestSCInitiatedOperationsFinalizeAfterCmp(t *testing.T) { server := NewTestServer(log, storage, nil, 1, sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) server.config = &Config{MessageEncoding: EncodingJSON} - server.storage = storage + server.SetStorageForTest(storage) server.RegisterHandlers() session := &Session{ ProtocolSessionState: ProtocolSessionState{ diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index 82a6240..d8b4c7c 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -27,7 +27,6 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/endpoint" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" pkgmioty "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/mioty" // Shared MIOTY helpers (FormatEUI64, EPStatus) - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/org" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" "github.com/Kiloiot/kilo-service-center/KC-DB/common/validation" "github.com/Kiloiot/kilo-service-center/KC-DB/storage" @@ -110,10 +109,6 @@ type Server struct { sessions map[string]*Session mu sync.RWMutex - // Concrete dependencies KEPT (needed by services) - connectionMgr *basestation.ConnectionManager // Real collaborator for ConnectionService - storage interfaces.Storage // Uses repository interfaces - // Injected services sessionSvc SessionService versionNegotiator VersionNegotiator @@ -126,7 +121,7 @@ type Server struct { tenantResolver TenantResolver // Queue-to-tenant mapping (replaces queueTenants map) // Organization resolution - orgResolver org.Resolver // Organization UUID → tenant ID resolution + orgResolver OrganizationDirectory // Organization UUID → tenant ID resolution // Certificate identity enforcement (strict mode): resolver maps the TLS // client certificate to a tenant/org identity at accept; the directory // reads registered station identity for connect-time enforcement @@ -134,12 +129,16 @@ type Server struct { bsDirectory RegisteredBaseStationDirectory defaultTenantID int64 // Community mode fallback tenant ID - // Keep essential infrastructure - deduplicator *MessageDeduplicator - eventStore interfaces.SystemEventStore - basestationRepo interfaces.BaseStationRepository - endpointRepo interfaces.EndpointRepository - keyEncryptor *crypto.KeyEncryptor + // Storage-boundary contracts (narrow, consumer-owned; satisfied + // structurally by the KC-DB repositories) + eventStore EventStore + basestationRepo BaseStationStore + endpointRepo EndpointDirectory + keyEncryptor NetworkKeyProtector + protocolMessages ProtocolMessageStore + dlrxStore DLRXStatusStore + bsStatusStore BaseStationStatusStore + downlinkQueueStore DownlinkQueueStore // Automatic propagation (BSSCI §5.8.3) propagationSvc propagation.Service @@ -153,6 +152,13 @@ type Server struct { // Shared uplink ingest pipeline (dedup, tenant resolution, persistence, SCACI, MQTT) uplinkIngestSvc UplinkIngestService + // Transactional attach / attach-propagate endpoint-session persistence + attachPersistence EndpointAttachmentPersistence + + // runtimeConfigured records that ConfigureRuntime supplied the circular + // dependencies; Start refuses to run without them. + runtimeConfigured bool + // Relay outbox writer for CE mode: enqueues unknown-endpoint uplinks for federation relay relayOutbox RelayOutboxWriter @@ -718,70 +724,120 @@ func (s *Server) validateOutboundMessage(session *Session, msgMap map[string]int return nil } -// NewServer creates a new BSSCI server with injected service dependencies -func NewServer( - cfg *Config, - log logger.Logger, - connectionMgr *basestation.ConnectionManager, - stor interfaces.Storage, - eventStore interfaces.SystemEventStore, - basestationRepo interfaces.BaseStationRepository, - endpointRepo interfaces.EndpointRepository, - tenantID int64, - sessionSvc SessionService, - versionNegotiator VersionNegotiator, - downlinkSvc DownlinkService, - statusSvc StatusService, - connectionRegistry BaseStationConnectionRegistry, - broadcaster SCACIBroadcaster, - queueSerializer QueueSerializer, - auditLogger AuditLogger, - tenantResolver TenantResolver, - orgResolver org.Resolver, - defaultTenantID int64, -) (*Server, error) { +// Dependencies carries every non-circular Server dependency for NewServer. +// StatusSvc is mandatory; feature-controlled collaborators (MQTT, blueprint +// decoding, detach validation, federation outbox, SCACI bridges) stay nil +// when their feature is off. The circular dependencies (propagation, downlink +// dispatcher) are supplied via ConfigureRuntime before Start. +type Dependencies struct { + // Core protocol services + SessionSvc SessionService + VersionNegotiator VersionNegotiator + DownlinkSvc DownlinkService + StatusSvc StatusService + ConnectionRegistry BaseStationConnectionRegistry + Broadcaster SCACIBroadcaster + QueueSerializer QueueSerializer + AuditLogger AuditLogger + TenantResolver TenantResolver + + // Storage-boundary contracts + EventStore EventStore + BaseStations BaseStationStore + Endpoints EndpointDirectory + AttachPersistence EndpointAttachmentPersistence + OrgDirectory OrganizationDirectory + KeyProtector NetworkKeyProtector + + // Certificate identity enforcement + CertIdentityResolver CertificateIdentityResolver + BaseStationDirectory RegisteredBaseStationDirectory + + // Ingest pipeline and routing + UplinkIngest UplinkIngestService + RoamingSvc RoamingService + DispositionResolver IngressDispositionResolver + RelayOutbox RelayOutboxWriter + + // Feature-controlled collaborators + DetachValidator DetachSignatureValidator + MQTTPublisher MQTTEventPublisher + BlueprintDecoder BlueprintDecoder + BlueprintResolver BlueprintResolver + SCACIEPStatusBroadcaster SCACIEPStatusBroadcaster + + // ProtocolMessages/DLRXStatus/BaseStationStatus/DownlinkQueue are the + // narrow storage views; each is satisfied directly by the matching + // KC-DB repository. + ProtocolMessages ProtocolMessageStore + DLRXStatus DLRXStatusStore + BaseStationStatus BaseStationStatusStore + DownlinkQueue DownlinkQueueStore + + TenantID int64 + DefaultTenantID int64 +} + +// RuntimeDependencies carries the collaborators that are constructed against +// the live *Server (circular) and therefore cannot be constructor arguments. +type RuntimeDependencies struct { + Propagation propagation.Service + DownlinkDispatcher DownlinkDispatcher +} + +// NewServer creates a BSSCI server from its dependency set; call +// ConfigureRuntime before Start to supply the circular collaborators. +func NewServer(cfg *Config, log logger.Logger, deps Dependencies) (*Server, error) { // StatusService is mandatory (single-writer architecture) - if statusSvc == nil { + if deps.StatusSvc == nil { return nil, fmt.Errorf("statusSvc is required for pending operation tracking") } ctx, cancel := context.WithCancel(context.Background()) - // Initialize key encryptor for sensitive data - keyEncryptor, err := crypto.NewKeyEncryptor() - if err != nil { - log.WarnContext(ctx, LogBSSCIFailedToInitializeKeyEncryptor, "error", err) - // Continue without encryption rather than failing - } - s := &Server{ - config: cfg, - logger: log, - sessions: make(map[string]*Session), - ctx: ctx, - cancel: cancel, - handlers: make(map[string]HandlerFunc), - connectionMgr: connectionMgr, - deduplicator: NewMessageDeduplicator(duplicateWindow(cfg)), - storage: stor, - tenantID: tenantID, - eventStore: eventStore, - basestationRepo: basestationRepo, - endpointRepo: endpointRepo, - keyEncryptor: keyEncryptor, - // Injected services - sessionSvc: sessionSvc, - versionNegotiator: versionNegotiator, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionRegistry: connectionRegistry, - queueSerializer: queueSerializer, - auditLogger: auditLogger, - tenantResolver: tenantResolver, - broadcaster: broadcaster, - // Organization resolution - orgResolver: orgResolver, - defaultTenantID: defaultTenantID, + config: cfg, + logger: log, + sessions: make(map[string]*Session), + ctx: ctx, + cancel: cancel, + handlers: make(map[string]HandlerFunc), + tenantID: deps.TenantID, + + eventStore: deps.EventStore, + basestationRepo: deps.BaseStations, + endpointRepo: deps.Endpoints, + keyEncryptor: deps.KeyProtector, + protocolMessages: deps.ProtocolMessages, + dlrxStore: deps.DLRXStatus, + bsStatusStore: deps.BaseStationStatus, + downlinkQueueStore: deps.DownlinkQueue, + attachPersistence: deps.AttachPersistence, + + sessionSvc: deps.SessionSvc, + versionNegotiator: deps.VersionNegotiator, + downlinkSvc: deps.DownlinkSvc, + statusSvc: deps.StatusSvc, + connectionRegistry: deps.ConnectionRegistry, + queueSerializer: deps.QueueSerializer, + auditLogger: deps.AuditLogger, + tenantResolver: deps.TenantResolver, + broadcaster: deps.Broadcaster, + + orgResolver: deps.OrgDirectory, + defaultTenantID: deps.DefaultTenantID, + + certIdentityResolver: deps.CertIdentityResolver, + bsDirectory: deps.BaseStationDirectory, + uplinkIngestSvc: deps.UplinkIngest, + roamingSvc: deps.RoamingSvc, + dispositionResolver: deps.DispositionResolver, + relayOutbox: deps.RelayOutbox, + detachValidator: deps.DetachValidator, + mqttPublisher: deps.MQTTPublisher, + blueprintDecoder: deps.BlueprintDecoder, + blueprintResolver: deps.BlueprintResolver, + scaciEPStatusBroadcaster: deps.SCACIEPStatusBroadcaster, } // Register command handlers @@ -790,63 +846,23 @@ func NewServer( return s, nil } -// SetPropagationService injects the propagation service after server construction -// Post-construction initialization to avoid circular dependency in main.go -// The propagation service depends on Server as AttachPropagateSender, so it must be -// constructed after the server exists and then injected back. -// -// BSSCI §5.8-5.8.3: Automatic endpoint propagation to multiple base stations -func (s *Server) SetPropagationService(svc propagation.Service) { - s.propagationSvc = svc -} - -// SetRoamingService injects the roaming service after server construction -// Post-construction initialization for modularity and testability. -// The roaming service handles multi-tenant endpoint ownership resolution. -func (s *Server) SetRoamingService(svc RoamingService) { - s.roamingSvc = svc -} - -// SetDispositionResolver injects the ingress disposition resolver. -// The resolver classifies each incoming uplink as Local, Relay, or Drop before ingest. -func (s *Server) SetDispositionResolver(r IngressDispositionResolver) { - s.dispositionResolver = r -} - -// SetUplinkIngestService injects the shared uplink ingest pipeline. -// When set, handleULData delegates dedup, tenant resolution, persistence, SCACI, and MQTT to this service. -func (s *Server) SetUplinkIngestService(svc UplinkIngestService) { - s.uplinkIngestSvc = svc -} - -// SetRelayOutboxWriter injects the CE federation relay outbox writer. -// When set, handleULData enqueues DispositionRelay uplinks instead of dropping them. -func (s *Server) SetRelayOutboxWriter(w RelayOutboxWriter) { - s.relayOutbox = w -} - -// GetDeduplicator exposes the server's message deduplicator for injection into the ingest service. -// The same deduplicator instance must be shared so dedup state is consistent. -func (s *Server) GetDeduplicator() *MessageDeduplicator { - return s.deduplicator -} - -// SetDetachValidator wires the detach signature validator for unknown endpoint validation. -// Called after service construction to enable signature validation via internal validator. -func (s *Server) SetDetachValidator(validator DetachSignatureValidator) { - s.detachValidator = validator -} - -// SetCertificateIdentityResolver wires certificate-identity resolution for -// TLS accept (CE composite resolver; ECE inherits it through the builders). -func (s *Server) SetCertificateIdentityResolver(resolver CertificateIdentityResolver) { - s.certIdentityResolver = resolver -} - -// SetBaseStationDirectory wires the registered-station directory used for -// connect-time certificate enforcement. -func (s *Server) SetBaseStationDirectory(directory RegisteredBaseStationDirectory) { - s.bsDirectory = directory +// ConfigureRuntime supplies the circular dependencies once, before Start: +// the propagation service and the downlink dispatcher are constructed +// against the live *Server and injected back here. +func (s *Server) ConfigureRuntime(deps RuntimeDependencies) error { + if deps.Propagation == nil { + return fmt.Errorf("propagation service is required") + } + if deps.DownlinkDispatcher == nil { + return fmt.Errorf("downlink dispatcher is required") + } + if s.config != nil && s.config.DetachSignatureValidationEnabled && s.detachValidator == nil { + return fmt.Errorf("detach signature validation is enabled but no validator is wired") + } + s.propagationSvc = deps.Propagation + s.downlinkDispatcher = deps.DownlinkDispatcher + s.runtimeConfigured = true + return nil } // defaultOrgForSessionTenant resolves the default organization for the @@ -924,46 +940,6 @@ func (s *Server) verifyCertificateFingerprint(ctx context.Context, session *Sess return nil } -// SetDownlinkDispatcher wires the auto-dispatch service (BSSCI §5.10.2) -// Called after service construction to enable dlOpen=true automatic dispatch. -func (s *Server) SetDownlinkDispatcher(dispatcher DownlinkDispatcher) { - s.downlinkDispatcher = dispatcher -} - -// SetBroadcaster wires the SCACI broadcaster for uplink/downlink forwarding. -// Called after SCACI server construction to complete the BSSCI → SCACI bridge. -// Preserves startup order: BSSCI → SCACI → wire broadcaster. -// -// Legacy scaci parameter removed - broadcaster is now the only interface. -func (s *Server) SetBroadcaster(broadcaster SCACIBroadcaster) { - s.broadcaster = broadcaster -} - -// SetSCACIEPStatusBroadcaster wires the SCACI EPStatus broadcaster for attach/detach forwarding. -// Called after SCACI server construction to complete the BSSCI → SCACI EPStatus bridge. -// Per SCACI §3.13: SC sends EPStatus to all ACs when endpoints attach/detach. -func (s *Server) SetSCACIEPStatusBroadcaster(broadcaster SCACIEPStatusBroadcaster) { - s.scaciEPStatusBroadcaster = broadcaster -} - -// SetBlueprintDecoder wires the blueprint decoder service for payload decoding. -// Per MIOTY Application Layer Specification: decode payloads using blueprint JSON definitions. -func (s *Server) SetBlueprintDecoder(decoder BlueprintDecoder) { - s.blueprintDecoder = decoder -} - -// SetBlueprintResolver wires the blueprint resolver service for blueprint lookup. -// Per MIOTY Application Layer Specification: resolve blueprints by TypeEUI or device model. -func (s *Server) SetBlueprintResolver(resolver BlueprintResolver) { - s.blueprintResolver = resolver -} - -// SetMQTTPublisher wires the MQTT event publisher for device lifecycle events. -// Called after publisher creation to enable outbound MQTT publishing from BSSCI handlers. -func (s *Server) SetMQTTPublisher(pub MQTTEventPublisher) { - s.mqttPublisher = pub -} - // formatTenantID formats the server's tenant ID as a string for database operations // // This helper centralizes tenant ID formatting to ensure consistency across @@ -1040,6 +1016,15 @@ func certsExist(certFile, keyFile, caFile string) bool { // (e.g., fresh deployment before certs are generated via UI), the listener // is deferred and a background goroutine polls until the certificates appear. func (s *Server) Start() error { + // The composition root must supply the circular dependencies before the + // server accepts traffic; an incompletely wired server refuses to start. + if !s.runtimeConfigured { + return fmt.Errorf("server runtime not configured: call ConfigureRuntime before Start") + } + if s.uplinkIngestSvc == nil { + return fmt.Errorf("uplink ingest service is required") + } + // The dlRxStatQry expiry sweep runs independently of the TLS listener path. s.startDLRXQueryExpiryWorker() @@ -1142,9 +1127,6 @@ func (s *Server) Stop() error { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToCloseListener, "error", err) } } - if s.deduplicator != nil { - s.deduplicator.Stop() - } s.wg.Wait() return nil } @@ -1326,10 +1308,10 @@ func (s *Server) handleConnection(conn net.Conn) { // Offline status transition is guarded by connection identity: a // reconnect that already replaced this connection keeps the base // station online - if session.BaseStationEUI != 0 && s.connectionMgr != nil { + if session.BaseStationEUI != 0 && s.connectionRegistry != nil { euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() - if err := s.connectionMgr.DisconnectBaseStationIfCurrent(ctx, euiBytes, session.ID); err != nil { + if err := s.connectionRegistry.DisconnectBaseStationIfCurrent(ctx, euiBytes, session.ID); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateOfflineStatus, "eui", session.BaseStationEUI, "error", err) @@ -1366,20 +1348,16 @@ func (s *Server) handleConnection(conn net.Conn) { // they are reissued with their original opIds on resume (BSSCI §4/§5.3). // Only a terminal session (rejected, provisional, or terminated above) // has its rows removed. - if session.DbSessionID != 0 && s.storage != nil && !wasActive { - // Guard against nil repository before calling methods - pendingOpsRepo := s.storage.PendingOperations() - if pendingOpsRepo != nil { - count, err := pendingOpsRepo.DeleteBySession(ctx, session.DbSessionID) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToDeletePendingOperations, - "error", err, - "sessionID", session.DbSessionID) - } else if count > 0 { - s.logger.InfoContext(ctx, LogBSSCIDeletedPendingOperations, - "sessionID", session.DbSessionID, - "count", count) - } + if session.DbSessionID != 0 && s.statusSvc != nil && !wasActive { + count, err := s.statusSvc.DeletePendingOperations(ctx, session) + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToDeletePendingOperations, + "error", err, + "sessionID", session.DbSessionID) + } else if count > 0 { + s.logger.InfoContext(ctx, LogBSSCIDeletedPendingOperations, + "sessionID", session.DbSessionID, + "count", count) } } }() @@ -1542,13 +1520,13 @@ func (s *Server) handleConnection(conn net.Conn) { session.LastSeen = time.Now() // Update last seen in database for connected basestations - if session.BaseStationEUI != 0 && s.connectionMgr != nil { + if session.BaseStationEUI != 0 && s.connectionRegistry != nil { euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() // Update last seen time in database go func() { ctx := s.sessionContext(session) - if err := s.connectionMgr.UpdateLastSeen(ctx, euiBytes); err != nil { + if err := s.connectionRegistry.UpdateLastSeen(ctx, euiBytes); err != nil { s.logger.DebugContext(ctx, LogBSSCIFailedToUpdateLastSeen, "eui", session.BaseStationEUI, "error", err) @@ -1778,6 +1756,10 @@ func (s *Server) sendMessage(session *Session, msg interface{}) error { // operation IDs) for recovery instead of retrying on the same connection. var ErrAmbiguousWrite = errors.New("ambiguous frame write") +// errAttachPersistenceUnavailable reports a missing endpoint attachment +// persister at a point that requires transactional persistence. +var errAttachPersistenceUnavailable = errors.New("endpoint attachment persistence not configured") + // closeTransportAfterWriteFailure closes the session transport after an // ambiguous frame write. Persisted pending operations are deliberately // preserved: the disconnect path marks the session resumable and resume @@ -1810,6 +1792,13 @@ func (s *Server) connectionEstablishmentTimeout() time.Duration { return defaultConnectionEstablishmentTimeout } +// EffectiveDuplicateWindow returns the configured deduplication window, +// falling back to the package default; the composition root uses it to +// construct the shared message deduplicator. +func (c *Config) EffectiveDuplicateWindow() time.Duration { + return duplicateWindow(c) +} + // duplicateWindow returns the configured uplink deduplication window, falling // back to the package default when unset. func duplicateWindow(cfg *Config) time.Duration { @@ -1869,7 +1858,7 @@ func (s *Server) dlrxCleanupInterval() time.Duration { // they do not linger pending forever. The worker stops when the server context // is cancelled. func (s *Server) startDLRXQueryExpiryWorker() { - if s.storage == nil || s.storage.DLRXStatus() == nil { + if s.dlrxStore == nil { return } s.wg.Add(1) @@ -1893,7 +1882,7 @@ func (s *Server) startDLRXQueryExpiryWorker() { // cutoff arithmetic is testable without the ticker. func (s *Server) sweepExpiredDLRXQueries(now time.Time) { cutoff := now.Add(-s.dlrxQueryTimeout()) - expired, err := s.storage.DLRXStatus().ExpireDLRXStatusQuery(s.safeCtx(), cutoff) + expired, err := s.dlrxStore.ExpireDLRXStatusQuery(s.safeCtx(), cutoff) if err != nil { s.logger.WarnContext(s.safeCtx(), LogBSSCIDLRXQueryExpirySweepFailed, "error", err) return @@ -2253,11 +2242,9 @@ func (s *Server) terminateInconsistentResume(ctx context.Context, previous *Sess if err := s.sessionSvc.TerminateSession(ctx, previous); err != nil { return fmt.Errorf("terminate inconsistent resume session: %w", err) } - if previous.DbSessionID != 0 && s.storage != nil { - if pendingOpsRepo := s.storage.PendingOperations(); pendingOpsRepo != nil { - if _, err := pendingOpsRepo.DeleteBySession(ctx, previous.DbSessionID); err != nil { - return fmt.Errorf("remove pending operations of inconsistent resume session: %w", err) - } + if previous.DbSessionID != 0 && s.statusSvc != nil { + if _, err := s.statusSvc.DeletePendingOperations(ctx, previous); err != nil { + return fmt.Errorf("remove pending operations of inconsistent resume session: %w", err) } } return nil @@ -3049,104 +3036,34 @@ func (s *Server) handleAttach(_ *Server, session *Session, msg *Message, data ma return nil } - tx, txErr := s.storage.BeginTx(ctx) - if txErr != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToBeginTransaction, "error", txErr) - if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperation, "error", err) - } - return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errDatabaseError)) - } - - var commitErr error - defer func() { - if commitErr != nil { - _ = tx.Rollback() - } - }() - - if err := tx.EndPoints().UpdateFields(ctx, tenantID, endpoint.ID, attachUpdates); err != nil { - commitErr = err - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToUpdateEndpointAttachMetadata, "error", err) + // The attachment persister owns the transactional endpoint + session + // upsert; any persistence failure keeps the exact response behavior: + // remove the pending operation and answer with the database error. + rec := AttachSessionRecord{ + TenantID: tenantID, + BSLookupTenantID: resolvedTenant(session, s.tenantID), + EndpointID: endpoint.ID, + EndpointUpdates: attachUpdates, + EncryptedKey: encryptedSessionKey, + //nolint:gosec // G115: attachCnt validated to be within 0..0xFFFFFF above + AttachCnt: uint32(attachCnt), + ShAddr: responseShAddr, + BaseStationEUI: session.BaseStationEUIBytes(), + } + if s.attachPersistence == nil { if err := s.removePendingOperation(session, msg.OpId); err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperation, "error", err) } return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errDatabaseError)) } - - endpointIDStr := fmt.Sprintf("%d", endpoint.ID) - activeSession, getErr := tx.EndPointSessions().GetActive(ctx, endpointIDStr) - if getErr != nil { - commitErr = getErr - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToLoadEndpointSession, "error", getErr) - if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperation, "error", err) + if err := s.attachPersistence.PersistAttachSession(ctx, rec); err != nil { + if rmErr := s.removePendingOperation(session, msg.OpId); rmErr != nil { + s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperation, "error", rmErr) } return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errDatabaseError)) } - now := time.Now().UTC() - - // Lookup base station ID for session enrichment (tenant-scoped) - var primaryBsID *int64 - if s.basestationRepo != nil { - bs, bsErr := s.basestationRepo.GetByEUI(ctx, resolvedTenant(session, s.tenantID), session.BaseStationEUIBytes()) - if bsErr == nil && bs != nil { - primaryBsID = &bs.ID - } - } - - // responseShAddr is uint16, model ShAddr is *int32 (INTEGER 0-65535) - shAddrInt32 := int32(responseShAddr) - - if activeSession != nil { - activeSession.SessionKey = encryptedSessionKey - //nolint:gosec // G115: attachCnt validated to be <= 0xFFFFFF on line 1431, safe for int32 - activeSession.AttachCnt = int32(attachCnt) - activeSession.LastActivityAt = now - activeSession.ShAddr = &shAddrInt32 - activeSession.PrimaryBaseStationID = primaryBsID - if err := tx.EndPointSessions().Update(ctx, activeSession); err != nil { - commitErr = err - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToUpdateEndpointSession, "error", err) - if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperation, "error", err) - } - return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errDatabaseError)) - } - } else { - newSession := &models.EndPointSession{ - TenantID: tenantID, - EndPointID: endpoint.ID, - SessionID: uuid.New().String(), - SessionKey: encryptedSessionKey, - //nolint:gosec // G115: attachCnt validated to be <= 0xFFFFFF on line 1431, safe for int32 - AttachCnt: int32(attachCnt), - Status: "active", - StartedAt: now, - LastActivityAt: now, - ShAddr: &shAddrInt32, - PrimaryBaseStationID: primaryBsID, - } - if err := tx.EndPointSessions().Create(ctx, newSession); err != nil { - commitErr = err - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToCreateEndpointSession, "error", err) - if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperation, "error", err) - } - return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errDatabaseError)) - } - } - - if err := tx.Commit(); err != nil { - commitErr = err - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToCommitAttachTransaction, "error", err) - if err := s.removePendingOperation(session, msg.OpId); err != nil { - s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToRemovePendingOperation, "error", err) - } - return s.sendError(session, msg.OpId, POSIX_EPROTO, ResolveErrorMessage(errDatabaseError)) - } - commitErr = nil + // Update radio metrics using selective update (preserves optional fields) // Update radio metrics using selective update (preserves optional fields) var eui models.EUI @@ -3538,8 +3455,8 @@ func (s *Server) handleDetach(_ *Server, session *Session, msg *Message, data ma // Normalize payload for audit completeness (DET-03: convert numeric arrays to concrete types) normalizedPayload := normalizeDetachPayload(data) - if s.storage != nil && s.storage.MIOTYMessages() != nil { - if err := s.storage.MIOTYMessages().CreateDetachMessage(ownerCtx, detachMsg, normalizedPayload); err != nil { + if s.protocolMessages != nil { + if err := s.protocolMessages.CreateDetachMessage(ownerCtx, detachMsg, normalizedPayload); err != nil { s.logger.WarnContext(ownerCtx, LogBSSCIFailedToPersistDetachMessage, "error", err, "epEui", epEUI, @@ -3672,7 +3589,7 @@ func (s *Server) handleAttachComplete(_ *Server, session *Session, msg *Message, epTID = int64(v) } if epTID > 0 && s.orgResolver != nil { - if orgUUID, orgErr := s.orgResolver.GetDefaultOrgForTenant(s.safeCtx(), epTID); orgErr == nil { + if orgUUID, orgErr := s.orgResolver.GetDefaultOrgForTenant(s.safeCtx(), epTID); orgErr == nil && orgUUID != uuid.Nil { epOwnerOrg = orgUUID.String() } } @@ -5461,76 +5378,21 @@ func (s *Server) SendAttachPropagate(sessionID string, endpointEUI uint64, nwkSn "long_blk_dist": longBlkDist, } - tx, txErr := s.storage.BeginTx(ownerCtx) // Use owner context - if txErr != nil { - return fmt.Errorf("failed to begin attach propagate transaction: %w", txErr) + // The attachment persister owns the owner-tenant transaction; + // error strings propagate to the caller unchanged. + if s.attachPersistence == nil { + return fmt.Errorf("failed to begin attach propagate transaction: %w", errAttachPersistenceUnavailable) } - var commitErr error - defer func() { - if commitErr != nil { - _ = tx.Rollback() - } - }() - - if err := tx.EndPoints().UpdateFields(ownerCtx, endpointTenantID, endpoint.ID, updates); err != nil { - commitErr = err - return fmt.Errorf("failed to update endpoint: %w", err) - } - - endpointIDStr := fmt.Sprintf("%d", endpoint.ID) - activeSession, getErr := tx.EndPointSessions().GetActive(ownerCtx, endpointIDStr) - if getErr != nil { - commitErr = getErr - return fmt.Errorf("failed to load endpoint session: %w", getErr) - } - - now := time.Now().UTC() - - // Lookup base station ID for session enrichment (tenant-scoped to owner) - var primaryBsID *int64 - if s.basestationRepo != nil { - bs, bsErr := s.basestationRepo.GetByEUI(ownerCtx, endpointTenantID, session.BaseStationEUIBytes()) - if bsErr == nil && bs != nil { - primaryBsID = &bs.ID - } - } - - // shortAddr is uint16, model ShAddr is *int32 (INTEGER 0-65535) - shAddrInt32 := int32(shortAddr) - - if activeSession != nil { - activeSession.SessionKey = encryptedKey - activeSession.LastActivityAt = now - activeSession.ShAddr = &shAddrInt32 - activeSession.PrimaryBaseStationID = primaryBsID - if err := tx.EndPointSessions().Update(ownerCtx, activeSession); err != nil { - commitErr = err - return fmt.Errorf("failed to update endpoint session: %w", err) - } - } else { - newSession := &models.EndPointSession{ - TenantID: endpointTenantID, // Use owner tenant - EndPointID: endpoint.ID, - SessionID: uuid.New().String(), - SessionKey: encryptedKey, - Status: string(models.SessionStatusActive), - UplinkMode: "standard", // Default uplink mode per BSSCI §5.2 - StartedAt: now, - LastActivityAt: now, - ShAddr: &shAddrInt32, - PrimaryBaseStationID: primaryBsID, - } - if err := tx.EndPointSessions().Create(ownerCtx, newSession); err != nil { - commitErr = err - return fmt.Errorf("failed to create endpoint session: %w", err) - } - } - - if err := tx.Commit(); err != nil { - commitErr = err - return fmt.Errorf("failed to commit attach propagate transaction: %w", err) + if err := s.attachPersistence.PersistAttachPropagateSession(ownerCtx, AttachPropagateSessionRecord{ + TenantID: endpointTenantID, + EndpointID: endpoint.ID, + EndpointUpdates: updates, + EncryptedKey: encryptedKey, + ShAddr: shortAddr, + BaseStationEUI: session.BaseStationEUIBytes(), + }); err != nil { + return err } - commitErr = nil s.logger.DebugContext(s.safeCtx(), LogBSSCIUpdatedEndpointWithAttachPropagateInfo, "epEui", endpointEUI, @@ -5562,7 +5424,7 @@ func (s *Server) SendAttachPropagate(sessionID string, endpointEUI uint64, nwkSn } // BSSCI §5.8.3: Persist attach propagate message to mioty_messages for audit trail - if s.storage != nil && s.storage.MIOTYMessages() != nil { + if s.protocolMessages != nil { bsEuiBytes := make([]byte, 8) binary.BigEndian.PutUint64(bsEuiBytes, session.BaseStationEUI) @@ -5607,8 +5469,8 @@ func (s *Server) SendAttachPropagate(sessionID string, endpointEUI uint64, nwkSn InterfaceType: mioty.InterfaceBSSCI, } - if s.storage != nil && s.storage.MIOTYMessages() != nil { - if err := s.storage.MIOTYMessages().CreateAttachPropagateMessage(ownerCtx, propagateMsg); err != nil { + if s.protocolMessages != nil { + if err := s.protocolMessages.CreateAttachPropagateMessage(ownerCtx, propagateMsg); err != nil { s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistAttachPropagateMessage, "error", err, "epEui", endpointEUI, @@ -6061,7 +5923,7 @@ func (s *Server) handleAttachPropagateComplete(_ *Server, session *Session, msg // Gated by hasEUI to ensure usable audit rows with valid ep_eui // NOTE: This is separate from the attPrp row persisted at send time // NwkSnKey intentionally omitted: already in attPrp row, avoid key duplication - if s.storage != nil && s.storage.MIOTYMessages() != nil { + if s.protocolMessages != nil { // Re-extract shAddr at this scope level for message persistence var msgShAddr uint16 if shAddrFloat, ok := pendingOp.Metadata["shortAddr"].(float64); ok { @@ -6122,7 +5984,7 @@ func (s *Server) handleAttachPropagateComplete(_ *Server, session *Session, msg InterfaceType: mioty.InterfaceBSSCI, } - if err := s.storage.MIOTYMessages().CreateAttachPropagateMessage(ownerCtx, completionMsg); err != nil { + if err := s.protocolMessages.CreateAttachPropagateMessage(ownerCtx, completionMsg); err != nil { s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToPersistAttachPropagateComplete, "error", err, "opId", msg.OpId, @@ -6260,8 +6122,8 @@ func (s *Server) SendDetachPropagate(sessionID string, endpointEUI uint64) error } // Persist to messages (non-blocking - log errors only) - if s.storage != nil && s.storage.MIOTYMessages() != nil { - if err := s.storage.MIOTYMessages().CreateDetachPropagateMessage(ownerCtx, propagateMsg); err != nil { + if s.protocolMessages != nil { + if err := s.protocolMessages.CreateDetachPropagateMessage(ownerCtx, propagateMsg); err != nil { s.logger.ErrorContext(ownerCtx, LogBSSCIFailedToPersistDetachPropagateMessage, "ep_eui", endpointEUI, "bs_eui", session.BaseStationEUI, @@ -6540,7 +6402,7 @@ func (s *Server) handleDetachPropagateComplete(_ *Server, session *Session, msg // BSSCI §5.9.3: Persist detPrpCmp to messages table // Gated by hasEUI to ensure usable audit rows with valid ep_eui - if s.storage != nil && s.storage.MIOTYMessages() != nil { + if s.protocolMessages != nil { var msgBsEUIBytes [8]byte binary.BigEndian.PutUint64(msgBsEUIBytes[:], session.BaseStationEUI) @@ -6559,7 +6421,7 @@ func (s *Server) handleDetachPropagateComplete(_ *Server, session *Session, msg completionMsg.OrgUUID = &orgStr } - if err := s.storage.MIOTYMessages().CreateDetachPropagateMessage(ownerCtx, completionMsg); err != nil { + if err := s.protocolMessages.CreateDetachPropagateMessage(ownerCtx, completionMsg); err != nil { s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToPersistDetachPropagateComplete, "error", err, "opId", msg.OpId, @@ -6716,25 +6578,12 @@ func (s *Server) updatePendingOperationMetadata(session *Session, opId int64, me return fmt.Errorf("%s: %w", ResolveErrorMessage(errFailedToMarshalMeta), err) } - // Update metadata in database using repository - err = s.storage.PendingOperations().UpdateMetadata(s.safeCtx(), sessionID, opId, json.RawMessage(metadataJSON)) - - if err != nil { - s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToUpdatePendingOperationMetadata, - "error", err, - "sessionID", sessionID, - "opId", opId) + // StatusService owns pending-operation persistence and its cache: the DB + // write happens first, the cache mirror only on success. + if err := s.statusSvc.UpdatePendingOperationMetadata(s.safeCtx(), session, opId, metadata, json.RawMessage(metadataJSON)); err != nil { return err } - // Also update in memory - // BSSCI §§5.11-5.12.3 Gap 1: Use StatusService for pending operation access - if s.statusSvc != nil { - if pendingOp, err := s.statusSvc.GetPendingOperation(session, opId); err == nil { - pendingOp.Metadata = metadata - } - } - s.logger.DebugContext(s.safeCtx(), LogBSSCIUpdatedPendingOperationMetadata, "sessionID", sessionID, "opId", opId, @@ -6773,13 +6622,13 @@ func isResumableScOperation(opID int64, opType string) bool { // loadPendingOperations loads pending operations from database for session resume (Issue #3: accepts *Session for SessionSlug field) func (s *Server) loadPendingOperations(session *Session) ([]*PendingOperation, error) { sessionID := session.DbSessionID - if sessionID == 0 || s.storage == nil { + if sessionID == 0 || s.statusSvc == nil { s.logger.DebugContext(s.safeCtx(), LogBSSCIDatabaseNotAvailableForPendingOpsLoad) return nil, nil } - // Retrieve pending operations via repository - repoOps, err := s.storage.PendingOperations().GetBySession(s.safeCtx(), sessionID) + // Retrieve the raw persisted rows through the pending-operation owner + repoOps, err := s.statusSvc.PersistedOperations(s.safeCtx(), sessionID) if err != nil { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToQueryPendingOperationsFromDatabase, "error", err, @@ -7390,19 +7239,13 @@ func extractSessionUUID(data interface{}) ([]byte, *CatalogError) { // updateSessionCounters updates the session operation counters in the database func (s *Server) updateSessionCounters(session *Session) { - if session.DbSessionID == 0 || s.storage == nil { + if session.DbSessionID == 0 || s.sessionSvc == nil { return } - // Update the session operation counters via the tenant-scoped repository - err := s.storage.BaseStationSessions().UpdateOperationIDs( - s.safeCtx(), - resolvedTenant(session, s.tenantID), - session.DbSessionID, - session.LastBsOpId, - session.LastScOpId) - - if err != nil { + // SessionService owns counter persistence via the atomic UpdateOperationIDs + // statement; this fire-and-forget path keeps its debug-only error handling. + if err := s.sessionSvc.UpdateSessionCounters(s.safeCtx(), session); err != nil { s.logger.DebugContext(s.safeCtx(), LogBSSCIFailedToUpdateSessionCounters, "error", err, "bsOpId", session.LastBsOpId, diff --git a/KC-Core/pkg/bssci/status_handlers.go b/KC-Core/pkg/bssci/status_handlers.go index 2e9dfa0..05bf552 100644 --- a/KC-Core/pkg/bssci/status_handlers.go +++ b/KC-Core/pkg/bssci/status_handlers.go @@ -215,7 +215,7 @@ func (s *Server) handleStatusResponse(_ *Server, session *Session, msg *Message, "fieldsUpdated", len(updates)) // Persist status history (BSSCI §3.5.2 BSSCI-3.5-HIST) - if s.storage != nil && s.storage.MIOTYBaseStationStatus() != nil { + if s.bsStatusStore != nil { tenantID := resolvedTenant(session, s.tenantID) statusRecord := &mioty.BaseStationStatusRecord{ @@ -236,7 +236,7 @@ func (s *Server) handleStatusResponse(_ *Server, session *Session, msg *Message, Longitude: longitude, Altitude: altitude, } - if err := s.storage.MIOTYBaseStationStatus().Create(ctx, statusRecord); err != nil { + if err := s.bsStatusStore.Create(ctx, statusRecord); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToPersistStatusHistory, "error", err) } } diff --git a/KC-Core/pkg/bssci/tenant_context_test.go b/KC-Core/pkg/bssci/tenant_context_test.go index 75f6264..c37e786 100644 --- a/KC-Core/pkg/bssci/tenant_context_test.go +++ b/KC-Core/pkg/bssci/tenant_context_test.go @@ -377,7 +377,7 @@ func TestULDataTenantResolution(t *testing.T) { // Note: stubMIOTYMessageRepo.CreateULDataMessage is a no-op in detach_integration_test.go // Full validation would require extending the stub to capture UL data messages // For now, verify that handleULData succeeds and sends proper response - _ = env.server.storage.MIOTYMessages().(*stubMIOTYMessageRepo) + _ = env.server.protocolMessages.(*stubMIOTYMessageRepo) t.Logf("PASS: UL data from endpoint tenant %d via session tenant %d handled correctly (expect tenant %d)", tt.endpointTenant, tt.sessionTenant, tt.expectTenant) diff --git a/KC-Core/pkg/bssci/test_helpers_test.go b/KC-Core/pkg/bssci/test_helpers_test.go index eb5ddb7..ab734ad 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -9,10 +9,14 @@ import ( "sync" "time" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + "github.com/google/uuid" ) // NewTestServer creates a Server instance for testing with access to unexported fields. @@ -61,7 +65,6 @@ func NewTestServer( s := &Server{ logger: log, - storage: storage, eventStore: eventStore, tenantID: tenantID, sessionSvc: sessionSvc, @@ -76,10 +79,9 @@ func NewTestServer( handlers: make(map[string]HandlerFunc), // Initialize handlers map for tests sessions: make(map[string]*Session), // Initialize sessions map for tests } - // Wire endpointRepo if storage is available (prevents nil panics in resolveEndpointTenantID) - if storage != nil { - s.endpointRepo = storage.EndPoints() - } + // Fan the storage facade out into the narrow store views the Server + // consumes (mirrors NewServer's wiring). + s.SetStorageForTest(storage) // Initialize broadcast hook (tests can override) return s } @@ -149,11 +151,47 @@ func (s *Server) SetEndpointRepository(repo interfaces.EndpointRepository) { s.endpointRepo = repo } -// SetConnectionManager sets the connection manager for testing. -func (s *Server) SetConnectionManager(mgr *basestation.ConnectionManager) { - s.connectionMgr = mgr +// SetConnectionManager is a no-op retained for test compatibility: the +// Server no longer holds the concrete connection manager (live-connection +// operations go through BaseStationConnectionRegistry). +func (s *Server) SetConnectionManager(_ *basestation.ConnectionManager) {} + +// Test-only wiring setters mirroring the removed production setters: the +// production Server is wired exclusively through Dependencies and +// ConfigureRuntime. + +func (s *Server) SetMQTTPublisher(pub MQTTEventPublisher) { s.mqttPublisher = pub } + +func (s *Server) SetUplinkIngestService(svc UplinkIngestService) { s.uplinkIngestSvc = svc } + +func (s *Server) SetDetachValidator(validator DetachSignatureValidator) { + s.detachValidator = validator } +func (s *Server) SetSCACIEPStatusBroadcaster(b SCACIEPStatusBroadcaster) { + s.scaciEPStatusBroadcaster = b +} + +func (s *Server) SetCertificateIdentityResolver(r CertificateIdentityResolver) { + s.certIdentityResolver = r +} + +func (s *Server) SetBaseStationDirectory(d RegisteredBaseStationDirectory) { s.bsDirectory = d } + +func (s *Server) SetDownlinkDispatcher(d DownlinkDispatcher) { s.downlinkDispatcher = d } + +func (s *Server) SetPropagationService(svc propagation.Service) { s.propagationSvc = svc } + +func (s *Server) SetRoamingService(svc RoamingService) { s.roamingSvc = svc } + +func (s *Server) SetDispositionResolver(r IngressDispositionResolver) { s.dispositionResolver = r } + +func (s *Server) SetRelayOutboxWriter(w RelayOutboxWriter) { s.relayOutbox = w } + +func (s *Server) SetBlueprintDecoder(d BlueprintDecoder) { s.blueprintDecoder = d } + +func (s *Server) SetBlueprintResolver(r BlueprintResolver) { s.blueprintResolver = r } + // CallHandleStatusResponse exposes the unexported handleStatusResponse method for testing. func (s *Server) CallHandleStatusResponse(session *Session, msg *Message, data map[string]interface{}) error { return s.handleStatusResponse(s, session, msg, data) @@ -249,9 +287,10 @@ func (s *Server) CallHandleULData(session *Session, msg *Message, data map[strin } // SetDeduplicator sets the deduplicator for testing. -func (s *Server) SetDeduplicator(dedup *MessageDeduplicator) { - s.deduplicator = dedup -} +// SetDeduplicator is a no-op retained for test compatibility: the +// deduplicator is root-owned and injected into the ingest service, never +// held by the Server. +func (s *Server) SetDeduplicator(_ *MessageDeduplicator) {} // NewTestServerWithBaseStationRepo creates a Server instance for testing with a custom base station repository. // This is useful for tests that need to verify tenant isolation by capturing repository call arguments. @@ -277,7 +316,6 @@ func NewTestServerWithBaseStationRepo( s := &Server{ logger: log, - storage: storage, basestationRepo: basestationRepo, tenantID: tenantID, sessionSvc: sessionSvc, @@ -291,7 +329,8 @@ func NewTestServerWithBaseStationRepo( versionNegotiator: staticTestVersionNegotiator{}, sessions: make(map[string]*Session), } - // Initialize broadcast hook (tests can override) + s.SetStorageForTest(storage) + s.basestationRepo = basestationRepo return s } @@ -499,6 +538,27 @@ type memoryStatusService struct { // recordErr, when set, is returned by Record* calls to exercise the // persist-failure abort path (no wire frame, counter gap only). recordErr error + // metadataUpdates records UpdatePendingOperationMetadata calls so tests + // can assert the persistence path now owned by the StatusService. + metadataUpdates []StatusMetadataUpdate +} + +// StatusMetadataUpdate captures one UpdatePendingOperationMetadata call. +type StatusMetadataUpdate struct { + SessionID int64 + OpId int64 + Metadata json.RawMessage +} + +// StatusMetadataUpdates returns the recorded metadata persistence calls of the +// in-memory status service (nil when svc is a different implementation). +func StatusMetadataUpdates(svc StatusService) []StatusMetadataUpdate { + if ms, ok := svc.(*memoryStatusService); ok { + ms.mu.RLock() + defer ms.mu.RUnlock() + return append([]StatusMetadataUpdate(nil), ms.metadataUpdates...) + } + return nil } func (m *memoryStatusService) RecordPendingOperation(_ context.Context, session *Session, opId int64, op *PendingOperation, _ int64) error { @@ -565,6 +625,38 @@ func (m *memoryStatusService) ExtractQueueMetadata(session *Session, opId int64) return } +func (m *memoryStatusService) UpdatePendingOperationMetadata(_ context.Context, session *Session, opId int64, metadata map[string]interface{}, metadataJSON json.RawMessage) error { + m.mu.Lock() + defer m.mu.Unlock() + m.metadataUpdates = append(m.metadataUpdates, StatusMetadataUpdate{ + SessionID: session.DbSessionID, + OpId: opId, + Metadata: append(json.RawMessage(nil), metadataJSON...), + }) + key := makeSessionOpKey(session, opId) + if op, ok := (*m.pendingOps)[key]; ok { + op.Metadata = metadata + } + return nil +} + +func (m *memoryStatusService) PersistedOperations(_ context.Context, _ int64) ([]PersistedOperation, error) { + return nil, nil +} + +func (m *memoryStatusService) DeletePendingOperations(_ context.Context, session *Session) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var count int64 + for key := range *m.pendingOps { + if key.SessionID == session.ID { + delete(*m.pendingOps, key) + count++ + } + } + return count, nil +} + func (m *memoryStatusService) ProcessOperationStatusUpdate(_ context.Context, _ *Session, _ int64, _ string) error { return nil } @@ -609,3 +701,182 @@ func CreateTestServices(log logger.Logger, _ interfaces.SystemEventStore) ( // This avoids ad-hoc fakes that break dependency boundaries return sessionSvc, nil, statusSvc, nil, nil, queueSerializer, nil, tenantResolver, nil } + +// SetStorageForTest fans a storage facade out into the narrow store views the +// Server consumes, mirroring NewServer's production wiring. Tests that inject +// a custom storage fake after construction must use this instead of assigning +// the storage field directly. +func (s *Server) SetStorageForTest(storage interfaces.Storage) { + s.attachPersistence = &storageTxPersister{server: s, storage: storage} + if storage == nil { + return + } + // Nil accessor results never clobber a fake a test wired directly, which + // matches the old direct-assignment semantics of server.storage. + if repo := storage.EndPoints(); repo != nil { + s.endpointRepo = repo + } + if repo := storage.MIOTYMessages(); repo != nil { + s.protocolMessages = repo + } + if repo := storage.DLRXStatus(); repo != nil { + s.dlrxStore = repo + } + if repo := storage.MIOTYBaseStationStatus(); repo != nil { + s.bsStatusStore = repo + } + if repo := storage.MIOTYDownlinks(); repo != nil { + s.downlinkQueueStore = repo + } +} + +// storageTxPersister mirrors the production attachment persister over the +// Server's live storage and base station repository fields so tests keep +// injecting transaction failures through their storage fakes. +type storageTxPersister struct { + server *Server + storage interfaces.Storage +} + +func (p *storageTxPersister) PersistAttachSession(ctx context.Context, rec AttachSessionRecord) error { + st := p.storage + if st == nil { + return errAttachPersistenceUnavailable + } + tx, txErr := st.BeginTx(ctx) + if txErr != nil { + p.server.logger.ErrorContext(ctx, LogBSSCIFailedToBeginTransaction, "error", txErr) + return txErr + } + var commitErr error + defer func() { + if commitErr != nil { + _ = tx.Rollback() + } + }() + if err := tx.EndPoints().UpdateFields(ctx, rec.TenantID, rec.EndpointID, rec.EndpointUpdates); err != nil { + commitErr = err + p.server.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateEndpointAttachMetadata, "error", err) + return err + } + activeSession, getErr := tx.EndPointSessions().GetActive(ctx, fmt.Sprintf("%d", rec.EndpointID)) + if getErr != nil { + commitErr = getErr + p.server.logger.ErrorContext(ctx, LogBSSCIFailedToLoadEndpointSession, "error", getErr) + return getErr + } + now := time.Now().UTC() + var primaryBsID *int64 + if p.server.basestationRepo != nil { + if bs, bsErr := p.server.basestationRepo.GetByEUI(ctx, rec.BSLookupTenantID, rec.BaseStationEUI); bsErr == nil && bs != nil { + primaryBsID = &bs.ID + } + } + shAddrInt32 := int32(rec.ShAddr) + if activeSession != nil { + activeSession.SessionKey = rec.EncryptedKey + //nolint:gosec // G115: AttachCnt is handler-validated to fit 24 bits + activeSession.AttachCnt = int32(rec.AttachCnt) + activeSession.LastActivityAt = now + activeSession.ShAddr = &shAddrInt32 + activeSession.PrimaryBaseStationID = primaryBsID + if err := tx.EndPointSessions().Update(ctx, activeSession); err != nil { + commitErr = err + p.server.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateEndpointSession, "error", err) + return err + } + } else { + newSession := &models.EndPointSession{ + TenantID: rec.TenantID, + EndPointID: rec.EndpointID, + SessionID: uuid.New().String(), + SessionKey: rec.EncryptedKey, + //nolint:gosec // G115: AttachCnt is handler-validated to fit 24 bits + AttachCnt: int32(rec.AttachCnt), + Status: "active", + StartedAt: now, + LastActivityAt: now, + ShAddr: &shAddrInt32, + PrimaryBaseStationID: primaryBsID, + } + if err := tx.EndPointSessions().Create(ctx, newSession); err != nil { + commitErr = err + p.server.logger.ErrorContext(ctx, LogBSSCIFailedToCreateEndpointSession, "error", err) + return err + } + } + if err := tx.Commit(); err != nil { + commitErr = err + p.server.logger.ErrorContext(ctx, LogBSSCIFailedToCommitAttachTransaction, "error", err) + return err + } + commitErr = nil + return nil +} + +func (p *storageTxPersister) PersistAttachPropagateSession(ctx context.Context, rec AttachPropagateSessionRecord) error { + st := p.storage + if st == nil { + return fmt.Errorf("failed to begin attach propagate transaction: %w", errAttachPersistenceUnavailable) + } + tx, txErr := st.BeginTx(ctx) + if txErr != nil { + return fmt.Errorf("failed to begin attach propagate transaction: %w", txErr) + } + var commitErr error + defer func() { + if commitErr != nil { + _ = tx.Rollback() + } + }() + if err := tx.EndPoints().UpdateFields(ctx, rec.TenantID, rec.EndpointID, rec.EndpointUpdates); err != nil { + commitErr = err + return fmt.Errorf("failed to update endpoint: %w", err) + } + activeSession, getErr := tx.EndPointSessions().GetActive(ctx, fmt.Sprintf("%d", rec.EndpointID)) + if getErr != nil { + commitErr = getErr + return fmt.Errorf("failed to load endpoint session: %w", getErr) + } + now := time.Now().UTC() + var primaryBsID *int64 + if p.server.basestationRepo != nil { + if bs, bsErr := p.server.basestationRepo.GetByEUI(ctx, rec.TenantID, rec.BaseStationEUI); bsErr == nil && bs != nil { + primaryBsID = &bs.ID + } + } + shAddrInt32 := int32(rec.ShAddr) + if activeSession != nil { + activeSession.SessionKey = rec.EncryptedKey + activeSession.LastActivityAt = now + activeSession.ShAddr = &shAddrInt32 + activeSession.PrimaryBaseStationID = primaryBsID + if err := tx.EndPointSessions().Update(ctx, activeSession); err != nil { + commitErr = err + return fmt.Errorf("failed to update endpoint session: %w", err) + } + } else { + newSession := &models.EndPointSession{ + TenantID: rec.TenantID, + EndPointID: rec.EndpointID, + SessionID: uuid.New().String(), + SessionKey: rec.EncryptedKey, + Status: string(models.SessionStatusActive), + UplinkMode: "standard", + StartedAt: now, + LastActivityAt: now, + ShAddr: &shAddrInt32, + PrimaryBaseStationID: primaryBsID, + } + if err := tx.EndPointSessions().Create(ctx, newSession); err != nil { + commitErr = err + return fmt.Errorf("failed to create endpoint session: %w", err) + } + } + if err := tx.Commit(); err != nil { + commitErr = err + return fmt.Errorf("failed to commit attach propagate transaction: %w", err) + } + commitErr = nil + return nil +} diff --git a/KC-Core/pkg/bssci/version_interop_test.go b/KC-Core/pkg/bssci/version_interop_test.go index 2dd43e9..c8ecfed 100644 --- a/KC-Core/pkg/bssci/version_interop_test.go +++ b/KC-Core/pkg/bssci/version_interop_test.go @@ -37,6 +37,12 @@ func (interopConnectionService) RegisterConnection(_ context.Context, _ *Session return nil } +func (interopConnectionService) DisconnectBaseStationIfCurrent(_ context.Context, _ [8]byte, _ string) error { + return nil +} + +func (interopConnectionService) UpdateLastSeen(_ context.Context, _ [8]byte) error { return nil } + // interopHarness drives a real Server through net.Pipe with framed wire // traffic in the configured encoding. type interopHarness struct { diff --git a/KC-Core/pkg/bssci/vm_handlers_test.go b/KC-Core/pkg/bssci/vm_handlers_test.go index 7d22bdf..fdcc88e 100644 --- a/KC-Core/pkg/bssci/vm_handlers_test.go +++ b/KC-Core/pkg/bssci/vm_handlers_test.go @@ -386,7 +386,6 @@ func createTestServerWithSession() *Server { broadcaster, queueSerializer, auditLogger, tenantResolver, ) - s.deduplicator = NewMessageDeduplicator(1000) return s } diff --git a/KC-Core/pkg/endpoint/state.go b/KC-Core/pkg/endpoint/state.go index e6b0e23..f8eb37d 100644 --- a/KC-Core/pkg/endpoint/state.go +++ b/KC-Core/pkg/endpoint/state.go @@ -6,10 +6,15 @@ package endpoint import ( "context" "time" - - "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" ) +// FieldUpdater is the single repository capability DetachEndpoint needs; +// both the full endpoint repository and narrower protocol-server views +// satisfy it. +type FieldUpdater interface { + UpdateFields(ctx context.Context, tenantID int64, endpointID int64, updates map[string]interface{}) error +} + // DetachEndpoint builds the canonical endpoint detach update map and applies it. // // This helper mirrors the logic from KC-Core/pkg/bssci/server.go:1673 and :3573 @@ -33,7 +38,7 @@ import ( // - SCACI deregister handler (KC-Core/pkg/scaci/handler_operations.go) with nil telemetry func DetachEndpoint( ctx context.Context, - repo interfaces.EndpointRepository, + repo FieldUpdater, tenantID int64, endpointID int64, telemetry map[string]interface{}, diff --git a/KC-Core/pkg/scaci/contracts.go b/KC-Core/pkg/scaci/contracts.go index 830af97..af8fae0 100644 --- a/KC-Core/pkg/scaci/contracts.go +++ b/KC-Core/pkg/scaci/contracts.go @@ -52,6 +52,7 @@ import ( "context" "crypto/x509" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" "github.com/Kiloiot/kilo-service-center/KC-DB/storage" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" @@ -126,7 +127,7 @@ type HandshakeService interface { // - string: Error token (errMajorVersionUnsupported, errInvalidVersionFormat) or "" on success // // Spec Reference: §2.1-2.3 version negotiation rules - NegotiateVersion(clientVersion string) (negotiatedVersion string, errToken string) + NegotiateVersion(ctx context.Context, clientVersion string) (negotiatedVersion string, errToken string) // ResolveResume validates session resumption per SCACI §3.3 and §§2.1-2.3 // @@ -288,10 +289,10 @@ type EndpointService interface { // - []error: Slice of errors (one per failed base station), empty if all succeeded // // Example Usage: - // if errs := svc.PropagateDetachToAll(epEui); len(errs) > 0 { + // if errs := svc.PropagateDetachToAll(ctx, epEui); len(errs) > 0 { // logger.Warn("Some base stations failed detach propagation", zap.Errors("errors", errs)) // } - PropagateDetachToAll(epEui uint64) []error + PropagateDetachToAll(ctx context.Context, epEui uint64) []error } // ULService schedules uplink transmissions per MIOTY §3.9 @@ -523,16 +524,16 @@ type SessionValidator interface { // // Parameters: // - req: Decoded Connect message from wire - // - opId: Operation ID from message header // // Returns: // - string: Error token from errors_catalog.go if validation fails, "" on success // // Example Usage: - // if errToken := s.sessionValidator.ValidateConnectFields(&req, opId); errToken != "" { + // if errToken := s.sessionValidator.ValidateConnectFields(&req); errToken != "" { // return s.sendErrorWithCatalog(conn, nil, opId, POSIX_EINVAL, errToken) // } - ValidateConnectFields(req *Connect, opId int64) string + // Pure and stateless: performs no I/O and no logging, so it takes no context. + ValidateConnectFields(req *Connect) string } // CertificateVerifier validates TLS client certificates for SCACI connections @@ -562,10 +563,10 @@ type CertificateVerifier interface { // - string: Error token from errors_catalog.go if validation fails, "" on success // // Example Usage: - // if errToken := s.certVerifier.VerifyCertificate(cert); errToken != "" { + // if errToken := s.certVerifier.VerifyCertificate(ctx, cert); errToken != "" { // return nil, nil, errToken // } - VerifyCertificate(cert *x509.Certificate) string + VerifyCertificate(ctx context.Context, cert *x509.Certificate) string } // OperationRecorder persists SCACI operations for resume safety (SCACI §3.4) @@ -698,7 +699,7 @@ type ErrorRecorder interface { // // Example Usage (handler_connect.go): // -// s.sessionPersistence.PersistConnectAsync(session, certFingerprint, certSubject, remoteAddr) +// s.sessionPersistence.PersistConnectAsync(ctx, session, certFingerprint, certSubject, remoteAddr) // // Handler continues without waiting for DB write type SessionPersistence interface { // PersistConnectAsync handles async session creation/update after Connect handshake @@ -723,7 +724,7 @@ type SessionPersistence interface { // - Spawns goroutine with 5s timeout (matches current ConnectPersistTimeout) // - Logs errors but doesn't propagate to handler // - Updates session.ID field with database-assigned ID on create - PersistConnectAsync(session *Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) + PersistConnectAsync(ctx context.Context, session *Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) // PersistConnectSync creates session synchronously, returning DB ID for operation logging. // @@ -791,3 +792,47 @@ type SessionPersistence interface { // - Already defined in server.go (lines 67-76) // - Used by EndpointService implementation for BSSCI integration // - No changes needed - existing interface is correct + +// SessionCounterStore persists the paired AC/SC operation ID counters +// (SCACI §3.2). Satisfied structurally by the SCACI session repository. +type SessionCounterStore interface { + UpdateOperationIDs(ctx context.Context, tenantID, sessionID int64, acOpId, scOpId int64) error +} + +// OperationStore owns the SCACI operation lifecycle rows used for the +// three-way handshake audit trail and resume replay (SCACI §3.2-§3.3). +// Satisfied structurally by the SCACI operation repository. +type OperationStore interface { + RecordOperation(ctx context.Context, req *models.SCACIOperationRequest) (*models.SCACIOperation, error) + UpdateOperationState(ctx context.Context, sessionID int64, opId int64, state models.OperationState, responseData map[string]interface{}) error + GetOperationByOpID(ctx context.Context, sessionID int64, opId int64) (*models.SCACIOperation, error) + GetPendingOperations(ctx context.Context, sessionID int64) ([]*models.SCACIOperation, error) +} + +// OrganizationDirectory resolves the default organization for a tenant. +// Satisfied structurally by org.Resolver implementations. +type OrganizationDirectory interface { + GetDefaultOrgForTenant(ctx context.Context, tenantID int64) (uuid.UUID, error) +} + +// SessionSnapshotSource provides lightweight snapshots of the connected base +// station sessions for propagation fan-out. Satisfied by the BSSCI server. +type SessionSnapshotSource interface { + ConnectedSessionsSnapshot() []propagation.BaseStationSession +} + +// EndpointPropagator triggers attach propagation for an endpoint across the +// given sessions. Satisfied by the propagation service. +type EndpointPropagator interface { + TriggerEndpointPropagate(ctx context.Context, endpointID int64, activeSessions []propagation.BaseStationSession) error +} + +// ErrorOperationStore covers the failed-operation persistence the error +// recorder performs (SCACI §3.14). Satisfied structurally by the SCACI +// operation repository. +type ErrorOperationStore interface { + UpdateOperationStateWithError(ctx context.Context, sessionID int64, opId int64, + state models.OperationState, errorCode int, errorToken string, errorMessage string, + responseData map[string]interface{}) error + CompleteFailedOperation(ctx context.Context, sessionID int64, opId int64, responseData map[string]interface{}) error +} diff --git a/KC-Core/pkg/scaci/error_recorder.go b/KC-Core/pkg/scaci/error_recorder.go index 30fe3bb..86d0a65 100644 --- a/KC-Core/pkg/scaci/error_recorder.go +++ b/KC-Core/pkg/scaci/error_recorder.go @@ -12,14 +12,14 @@ import ( // errorRecorderImpl implements the ErrorRecorder interface per SCACI §3.14. // It persists error information to the operation log and emits system events. type errorRecorderImpl struct { - operationRepo interfaces.SCACIOperationRepository + operationRepo ErrorOperationStore eventStore interfaces.SystemEventStore log logger.Logger } // NewErrorRecorder creates a new ErrorRecorder implementation. func NewErrorRecorder( - operationRepo interfaces.SCACIOperationRepository, + operationRepo ErrorOperationStore, eventStore interfaces.SystemEventStore, log logger.Logger, ) ErrorRecorder { diff --git a/KC-Core/pkg/scaci/handler_connect.go b/KC-Core/pkg/scaci/handler_connect.go index 5bcda6f..8af2785 100644 --- a/KC-Core/pkg/scaci/handler_connect.go +++ b/KC-Core/pkg/scaci/handler_connect.go @@ -40,22 +40,20 @@ import ( // - Community fallback to default org (when cert parsing fails) // - Version negotiation, session resumption, session creation func (s *Server) handleConnect(conn net.Conn, session **Session, cert *x509.Certificate, opId int64, payload []byte) error { - // Pre-session logging: Session does not exist until Connect handshake completes. - // Certificate CN logged as explicit field; tenant resolution happens in HandshakeService. - // context.Background() is architecturally correct here - no session context available. - // - // Pre-session contexts cannot use sessionContext() because the session - // doesn't exist yet. The 5 context.Background() calls in handleConnect are intentional. + // Pre-session logging: Session does not exist until Connect handshake + // completes, so these sites use s.safeCtx() (a plain value-free context). + // Certificate CN is logged as an explicit field; tenant resolution happens + // in HandshakeService. certCN := "unknown" if cert != nil && cert.Subject.CommonName != "" { certCN = cert.Subject.CommonName } - s.logger.DebugContext(context.Background(), LogSCACIProcessingConnect, "certCN", certCN) + s.logger.DebugContext(s.safeCtx(), LogSCACIProcessingConnect, "certCN", certCN) // Step 1: Decode Connect message from payload (transport layer) var req Connect if err := msgpack.Unmarshal(payload, &req); err != nil { - s.logger.ErrorContext(context.Background(), LogSCACIDecodeConnectFailed, "error", err) + s.logger.ErrorContext(s.safeCtx(), LogSCACIDecodeConnectFailed, "error", err) return s.sendErrorWithCatalog(conn, nil, opId, POSIX_EINVAL, errInvalidConnectFormat) } @@ -63,27 +61,27 @@ func (s *Server) handleConnect(conn net.Conn, session **Session, cert *x509.Cert // SCACI §3.3-02: Connect MUST use opId == OpIDConnect (0) if opId != OpIDConnect { - s.logger.ErrorContext(context.Background(), LogSCACIConnectOpIDMustBeZero, "opId", opId) + s.logger.ErrorContext(s.safeCtx(), LogSCACIConnectOpIDMustBeZero, "opId", opId) _ = s.sendErrorWithCatalog(conn, nil, opId, POSIX_EINVAL, errConnectOpIdMustBeZero) _ = conn.Close() return fmt.Errorf("invalid connect opId") } // SCACI §3.3.1-01: Validate mandatory fields via sessionValidator - if errToken := s.sessionValidator.ValidateConnectFields(&req, opId); errToken != "" { + if errToken := s.sessionValidator.ValidateConnectFields(&req); errToken != "" { _ = s.sendErrorWithCatalog(conn, nil, opId, POSIX_EINVAL, errToken) _ = conn.Close() return fmt.Errorf("connect validation failed: %s", errToken) } // Debug: Log resume field state - s.logger.DebugContext(context.Background(), LogSCACIConnectResumeFields, + s.logger.DebugContext(s.safeCtx(), LogSCACIConnectResumeFields, "hasSnAcOpId", req.SnAcOpId != nil, "hasSnScOpId", req.SnScOpId != nil) // Step 3: Delegate to HandshakeService for business logic // Service handles: tenant resolution, version negotiation, session resumption, session creation, metadata - ctx := context.Background() + ctx := s.safeCtx() newSession, resp, errToken := s.handshakeSvc.ValidateConnect(ctx, &req, cert) if errToken != "" { // Service returned error token - sendErrorWithCatalog handles POSIXCode resolution @@ -308,7 +306,7 @@ func (s *Server) handleConnectComplete(conn net.Conn, session *Session, opId int // Use session.NegotiatedVersion set during handleConnect (SCACI §§2.1-2.3) // Skip if session was sync-persisted in handleConnect (SyncPersisted flag set for fresh sessions) if !session.SyncPersisted { - s.sessionPersistence.PersistConnectAsync(session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, session.NegotiatedVersion) + s.sessionPersistence.PersistConnectAsync(s.sessionContext(session), session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, session.NegotiatedVersion) } // SCACI §1: Replay pending operations on successful session resume diff --git a/KC-Core/pkg/scaci/handler_connect_test.go b/KC-Core/pkg/scaci/handler_connect_test.go index 5984593..b1c5e25 100644 --- a/KC-Core/pkg/scaci/handler_connect_test.go +++ b/KC-Core/pkg/scaci/handler_connect_test.go @@ -48,11 +48,11 @@ func TestHandshakeServiceIntegration(t *testing.T) { negotiatedVersion := "1.0.0" // Configure mock expectations - mockHandshake.On("NegotiateVersion", clientVersion). + mockHandshake.On("NegotiateVersion", mock.Anything, clientVersion). Return(negotiatedVersion, "") // Empty error token = success // Execute service method - gotVersion, errToken := mockHandshake.NegotiateVersion(clientVersion) + gotVersion, errToken := mockHandshake.NegotiateVersion(testutil.TestContext(), clientVersion) // Verify behavior assert.Equal(t, negotiatedVersion, gotVersion) @@ -67,10 +67,10 @@ func TestHandshakeServiceVersionError(t *testing.T) { clientVersion := "2.0.0" // Unsupported major version expectedError := errMajorVersionUnsupported - mockHandshake.On("NegotiateVersion", clientVersion). + mockHandshake.On("NegotiateVersion", mock.Anything, clientVersion). Return("", expectedError) - gotVersion, errToken := mockHandshake.NegotiateVersion(clientVersion) + gotVersion, errToken := mockHandshake.NegotiateVersion(testutil.TestContext(), clientVersion) assert.Empty(t, gotVersion) assert.Equal(t, expectedError, errToken) @@ -239,13 +239,12 @@ func TestSessionValidatorSuccess(t *testing.T) { 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, }, } - opId := int64(0) // Connect must use opId 0 per §3.3-02 // Mock successful validation - mockValidator.On("ValidateConnectFields", req, opId). + mockValidator.On("ValidateConnectFields", req). Return("") // Empty token = success - errToken := mockValidator.ValidateConnectFields(req, opId) + errToken := mockValidator.ValidateConnectFields(req) assert.Empty(t, errToken, "Expected successful validation") mockValidator.AssertExpectations(t) @@ -263,13 +262,12 @@ func TestSessionValidatorMissingVersion(t *testing.T) { 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, }, } - opId := int64(0) // Mock validation failure - mockValidator.On("ValidateConnectFields", req, opId). + mockValidator.On("ValidateConnectFields", req). Return(errMissingVersion) - errToken := mockValidator.ValidateConnectFields(req, opId) + errToken := mockValidator.ValidateConnectFields(req) assert.Equal(t, errMissingVersion, errToken) mockValidator.AssertExpectations(t) @@ -287,13 +285,12 @@ func TestSessionValidatorInvalidOpId(t *testing.T) { 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, }, } - opId := int64(5) // Invalid - Connect must use 0 // Mock validation failure for non-zero opId - mockValidator.On("ValidateConnectFields", req, opId). + mockValidator.On("ValidateConnectFields", req). Return(errConnectOpIdMustBeZero) - errToken := mockValidator.ValidateConnectFields(req, opId) + errToken := mockValidator.ValidateConnectFields(req) assert.Equal(t, errConnectOpIdMustBeZero, errToken) mockValidator.AssertExpectations(t) @@ -306,10 +303,10 @@ func TestCertificateVerifierSuccess(t *testing.T) { testCert := stubCert("ac-tenant-1") // Mock successful verification - mockVerifier.On("VerifyCertificate", testCert). + mockVerifier.On("VerifyCertificate", mock.Anything, testCert). Return("") // Empty token = success - errToken := mockVerifier.VerifyCertificate(testCert) + errToken := mockVerifier.VerifyCertificate(testutil.TestContext(), testCert) assert.Empty(t, errToken, "Expected successful certificate verification") mockVerifier.AssertExpectations(t) @@ -322,10 +319,10 @@ func TestCertificateVerifierExpired(t *testing.T) { testCert := stubCert("ac-tenant-1") // Mock expired certificate error - mockVerifier.On("VerifyCertificate", testCert). + mockVerifier.On("VerifyCertificate", mock.Anything, testCert). Return(ErrCertExpired) - errToken := mockVerifier.VerifyCertificate(testCert) + errToken := mockVerifier.VerifyCertificate(testutil.TestContext(), testCert) assert.Equal(t, ErrCertExpired, errToken) mockVerifier.AssertExpectations(t) @@ -338,10 +335,10 @@ func TestCertificateVerifierMissingClientAuth(t *testing.T) { testCert := stubCert("ac-tenant-1") // Mock missing ClientAuth extended key usage - mockVerifier.On("VerifyCertificate", testCert). + mockVerifier.On("VerifyCertificate", mock.Anything, testCert). Return(ErrCertMissingClientAuth) - errToken := mockVerifier.VerifyCertificate(testCert) + errToken := mockVerifier.VerifyCertificate(testutil.TestContext(), testCert) assert.Equal(t, ErrCertMissingClientAuth, errToken) mockVerifier.AssertExpectations(t) diff --git a/KC-Core/pkg/scaci/handler_operations.go b/KC-Core/pkg/scaci/handler_operations.go index 24d874d..3415572 100644 --- a/KC-Core/pkg/scaci/handler_operations.go +++ b/KC-Core/pkg/scaci/handler_operations.go @@ -444,7 +444,7 @@ func (s *Server) handleDeregisterComplete(conn net.Conn, session *Session, opId } // Trigger BSSCI detach propagation to base stations - if errs := s.endpointSvc.PropagateDetachToAll(epEui); len(errs) > 0 { + if errs := s.endpointSvc.PropagateDetachToAll(ctx, epEui); len(errs) > 0 { detachErrorCount = len(errs) s.logger.WarnContext(s.sessionContext(session), LogSCACIDetachPropagationErrors, "count", detachErrorCount) } else { diff --git a/KC-Core/pkg/scaci/handler_operations_test.go b/KC-Core/pkg/scaci/handler_operations_test.go index 33699e1..381ba72 100644 --- a/KC-Core/pkg/scaci/handler_operations_test.go +++ b/KC-Core/pkg/scaci/handler_operations_test.go @@ -963,7 +963,7 @@ func TestHandleDeregister_CachesEpEui(t *testing.T) { func TestHandleDeregisterComplete_UsesCache(t *testing.T) { // Setup mock services mockEndpoint := new(MockEndpointService) - mockEndpoint.On("PropagateDetachToAll", uint64(0x1234567890ABCDEF)).Return(nil) + mockEndpoint.On("PropagateDetachToAll", mock.Anything, uint64(0x1234567890ABCDEF)).Return(nil) mockDL := new(MockDLService) // GetDownlinkQueue returns empty list (no downlinks to revoke) @@ -1033,7 +1033,7 @@ func TestRevokeEndpointDownlinks_NilDLSvc_Graceful(t *testing.T) { func TestHandleDeregisterComplete_DBFallback(t *testing.T) { // Setup mock services mockEndpoint := new(MockEndpointService) - mockEndpoint.On("PropagateDetachToAll", uint64(0x1234567890ABCDEF)).Return(nil) + mockEndpoint.On("PropagateDetachToAll", mock.Anything, uint64(0x1234567890ABCDEF)).Return(nil) mockDL := new(MockDLService) mockDL.On("GetDownlinkQueue", mock.Anything, mock.Anything, mock.Anything).Return([]*storage.DownlinkMessage{}, nil) @@ -1785,7 +1785,7 @@ func TestHandleDeregisterComplete_CleanupMetadataCapture(t *testing.T) { // Setup mock services with specific return values mockEndpoint := new(MockEndpointService) // PropagateDetachToAll returns 2 errors to verify detachErrorCount - mockEndpoint.On("PropagateDetachToAll", uint64(0x70B3D59CD000089B)).Return([]error{ + mockEndpoint.On("PropagateDetachToAll", mock.Anything, uint64(0x70B3D59CD000089B)).Return([]error{ assert.AnError, assert.AnError, }) @@ -1897,7 +1897,7 @@ func TestHandleDeregisterComplete_CleanSuccess(t *testing.T) { // Setup mock services - all succeed with no errors mockEndpoint := new(MockEndpointService) // PropagateDetachToAll returns empty error slice (all succeeded) - mockEndpoint.On("PropagateDetachToAll", uint64(0x70B3D59CD000089B)).Return([]error{}) + mockEndpoint.On("PropagateDetachToAll", mock.Anything, uint64(0x70B3D59CD000089B)).Return([]error{}) mockDL := new(MockDLService) // GetDownlinkQueue returns 2 downlinks diff --git a/KC-Core/pkg/scaci/log_messages.go b/KC-Core/pkg/scaci/log_messages.go index a0ac875..202cff7 100644 --- a/KC-Core/pkg/scaci/log_messages.go +++ b/KC-Core/pkg/scaci/log_messages.go @@ -161,6 +161,8 @@ const ( LogSCACIDeregisterHandshakeComplete = "Deregister handshake complete" LogSCACILoadDeregisterOpFailed = "Failed to load deregister operation" LogSCACIDetachPropagationErrors = "Detach propagation had errors" + // LogSCACIDetachPropagatorUnavailable is logged when detach propagation is skipped because no propagator is wired. + LogSCACIDetachPropagatorUnavailable = "DetachPropagator not available, skipping propagation" LogSCACIDetachPropagationSent = "Detach propagation sent to all base stations" LogSCACIRevokeDownlinksFailed = "Failed to revoke downlinks" LogSCACIDeregisterCleanupStart = "Starting deregister cleanup" @@ -363,4 +365,12 @@ const ( LogSCACICommandMismatch = "SCACI message command mismatch" LogSCACIRecordEventFailed = "Failed to record SCACI error event" LogSCACISentErrorAck = "Sent error acknowledgment" + // LogSCACICertOrgResolutionFallback is logged when certificate org resolution fails and community fallback applies. + LogSCACICertOrgResolutionFallback = "Certificate org resolution failed, using community fallback" + // LogSCACISoftwareVersionNotConfigured is logged when the ConnectResponse omits swVersion because none is configured. + LogSCACISoftwareVersionNotConfigured = "Software version not configured - ConnectResponse will omit swVersion field" + // LogSCACIUsingDevelopmentSoftwareVersion is logged when a development software version is used in the ConnectResponse. + LogSCACIUsingDevelopmentSoftwareVersion = "Using development software version in ConnectResponse" + // LogSCACISublayerHandlerInvoked is logged when a registered sublayer handler dispatches a message. + LogSCACISublayerHandlerInvoked = "SCACI sublayer handler invoked" ) diff --git a/KC-Core/pkg/scaci/mocks_test.go b/KC-Core/pkg/scaci/mocks_test.go index e81598c..a389935 100644 --- a/KC-Core/pkg/scaci/mocks_test.go +++ b/KC-Core/pkg/scaci/mocks_test.go @@ -66,8 +66,8 @@ func (m *MockHandshakeService) ValidateConnect( } // NegotiateVersion mocks HandshakeService.NegotiateVersion -func (m *MockHandshakeService) NegotiateVersion(clientVersion string) (string, string) { - args := m.Called(clientVersion) +func (m *MockHandshakeService) NegotiateVersion(ctx context.Context, clientVersion string) (string, string) { + args := m.Called(ctx, clientVersion) return args.String(0), args.String(1) } @@ -169,8 +169,8 @@ type MockSessionValidator struct { } // ValidateConnectFields mocks SessionValidator.ValidateConnectFields -func (m *MockSessionValidator) ValidateConnectFields(req *Connect, opId int64) string { - args := m.Called(req, opId) +func (m *MockSessionValidator) ValidateConnectFields(req *Connect) string { + args := m.Called(req) return args.String(0) } @@ -183,8 +183,8 @@ type MockCertificateVerifier struct { } // VerifyCertificate mocks CertificateVerifier.VerifyCertificate -func (m *MockCertificateVerifier) VerifyCertificate(cert *x509.Certificate) string { - args := m.Called(cert) +func (m *MockCertificateVerifier) VerifyCertificate(ctx context.Context, cert *x509.Certificate) string { + args := m.Called(ctx, cert) return args.String(0) } @@ -224,8 +224,8 @@ type MockSessionPersistence struct { } // PersistConnectAsync mocks SessionPersistence.PersistConnectAsync -func (m *MockSessionPersistence) PersistConnectAsync(session *Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) { - m.Called(session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion) +func (m *MockSessionPersistence) PersistConnectAsync(ctx context.Context, session *Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) { + m.Called(ctx, session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion) } // PersistConnectSync mocks SessionPersistence.PersistConnectSync @@ -344,8 +344,8 @@ func (m *MockDLService) GetDownlinkByPacketCnt( // ============================================================================ // PropagateDetachToAll mocks EndpointService.PropagateDetachToAll -func (m *MockEndpointService) PropagateDetachToAll(epEui uint64) []error { - args := m.Called(epEui) +func (m *MockEndpointService) PropagateDetachToAll(ctx context.Context, epEui uint64) []error { + args := m.Called(ctx, epEui) if args.Get(0) == nil { return nil } diff --git a/KC-Core/pkg/scaci/server.go b/KC-Core/pkg/scaci/server.go index e1fae8f..bc3c46c 100644 --- a/KC-Core/pkg/scaci/server.go +++ b/KC-Core/pkg/scaci/server.go @@ -21,12 +21,12 @@ import ( "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/config" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" pkgmioty "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/mioty" // Shared MIOTY helpers (FormatEUI64, EPStatus) - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/org" // Organization resolver for propagation context - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" // BSSCI §5.8-5.8.3 attach propagation contracts - "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/scheduler" // Import neutral scheduler contracts + + // Organization resolver for propagation context + // BSSCI §5.8-5.8.3 attach propagation contracts + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/scheduler" // Import neutral scheduler contracts dbconfig "github.com/Kiloiot/kilo-service-center/KC-DB/common/config" "github.com/Kiloiot/kilo-service-center/KC-DB/common/encoding" - "github.com/Kiloiot/kilo-service-center/KC-DB/storage/interfaces" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" pkgcontext "github.com/Kiloiot/kilo-service-center/pkg/context" @@ -126,9 +126,9 @@ type Server struct { sessions map[net.Conn]*Session sessionsMu sync.RWMutex - // Dependencies - sessionRepo interfaces.SCACISessionRepository - operationRepo interfaces.SCACIOperationRepository // Operation lifecycle tracking (three-way handshake - cross-cutting concern) + // Dependencies (narrow consumer-owned storage contracts) + sessionRepo SessionCounterStore + operationRepo OperationStore // Operation lifecycle tracking (three-way handshake - cross-cutting concern) // Domain services handshakeSvc HandshakeService // Connect/version negotiation/session resumption @@ -144,11 +144,11 @@ type Server struct { errorRecorder ErrorRecorder // Error persistence and events (§3.14) // Organization resolution - orgResolver org.Resolver // Organization UUID → tenant ID resolution for propagation context + orgResolver OrganizationDirectory // Default organization lookup for propagation context // BSSCI propagation (§5.8-5.8.3) - sessionSnapshotProvider propagation.SessionSnapshotProvider // Provides connected BS sessions - propagationSvc propagation.Service // Orchestrates attach/detach propagation + sessionSnapshotProvider SessionSnapshotSource // Provides connected BS sessions + propagationSvc EndpointPropagator // Orchestrates attach propagation // Sublayer handlers (SCACI §4) // sublayerHandlers maps sublayer commands (e.g., "rc.dir") to handlers @@ -188,8 +188,8 @@ type Server struct { func NewServer( cfg *Config, log logger.Logger, - sessionRepo interfaces.SCACISessionRepository, - operationRepo interfaces.SCACIOperationRepository, + sessionRepo SessionCounterStore, + operationRepo OperationStore, handshakeSvc HandshakeService, endpointSvc EndpointService, ulSvc ULService, @@ -198,9 +198,10 @@ func NewServer( sessionValidator SessionValidator, operationRecorder OperationRecorder, sessionPersistence SessionPersistence, - orgResolver org.Resolver, - sessionSnapshotProvider propagation.SessionSnapshotProvider, - propagationSvc propagation.Service, + orgResolver OrganizationDirectory, + sessionSnapshotProvider SessionSnapshotSource, + propagationSvc EndpointPropagator, + errorRecorder ErrorRecorder, ) (*Server, error) { // Validate required dependencies (fail-fast pattern per BSSCI server.go:641-644) if cfg == nil { @@ -254,6 +255,7 @@ func NewServer( sessionValidator: sessionValidator, operationRecorder: operationRecorder, sessionPersistence: sessionPersistence, + errorRecorder: errorRecorder, orgResolver: orgResolver, sessionSnapshotProvider: sessionSnapshotProvider, propagationSvc: propagationSvc, @@ -262,18 +264,22 @@ func NewServer( }, nil } -// SetErrorRecorder sets the ErrorRecorder per SCACI §3.14 -// -// This setter allows wiring the ErrorRecorder after server construction, -// supporting gradual rollout and testing scenarios where the recorder -// may not be available at construction time. -func (s *Server) SetErrorRecorder(er ErrorRecorder) { - s.errorRecorder = er +// safeCtx returns the context for pre-session and lifecycle logging. SCACI +// lifecycle is owned by the shutdown channel, so this is a plain Background +// context: it contributes no cancellation, only a consistent *Context logging +// call shape. +func (s *Server) safeCtx() context.Context { + return context.Background() } // sessionContext creates a context enriched with SCACI session metadata for structured logging. // The logger's extractContextFields will automatically inject tenant_id and organization_id. +// Rooted in context.Background() on purpose: it carries values, never cancellation. +// Nil-safe: a nil session yields the plain pre-session context. func (s *Server) sessionContext(session *Session) context.Context { + if session == nil { + return s.safeCtx() + } ctx := context.Background() // SCACI sessions carry their own tenant ID @@ -317,7 +323,7 @@ func (s *Server) Start() error { return s.startTLSListener() } - s.logger.Warn(LogSCACICertsNotFound, + s.logger.WarnContext(s.safeCtx(), LogSCACICertsNotFound, "cert", s.config.TLS.CertFile, "key", s.config.TLS.KeyFile, "ca", s.config.TLS.CAFile) @@ -339,15 +345,15 @@ func (s *Server) waitForCertsAndStart() { for { select { case <-s.shutdown: - s.logger.Info(LogSCACIDeferredListenerCancelled) + s.logger.InfoContext(s.safeCtx(), LogSCACIDeferredListenerCancelled) return case <-ticker.C: if !scaciCertsExist(s.config.TLS) { continue } - s.logger.Info(LogSCACICertsDetected) + s.logger.InfoContext(s.safeCtx(), LogSCACICertsDetected) if err := s.startTLSListener(); err != nil { - s.logger.Error(LogSCACIDeferredListenerFailed, "error", err) + s.logger.ErrorContext(s.safeCtx(), LogSCACIDeferredListenerFailed, "error", err) continue } return @@ -368,7 +374,7 @@ func (s *Server) startTLSListener() error { } s.listener = listener - s.logger.Info(LogSCACIServerListening, + s.logger.InfoContext(s.safeCtx(), LogSCACIServerListening, "address", s.config.ListenAddr, "tls", "required", "spec", "MIOTY SCACI v1.0.0") @@ -392,7 +398,7 @@ func (s *Server) startTLSListener() error { // Returns: // - error: Shutdown error (currently always nil) func (s *Server) Stop() error { - s.logger.Info(LogSCACIServerStopping) + s.logger.InfoContext(s.safeCtx(), LogSCACIServerStopping) close(s.shutdown) if s.listener != nil { @@ -400,7 +406,7 @@ func (s *Server) Stop() error { } s.wg.Wait() - s.logger.Info(LogSCACIServerStopped) + s.logger.InfoContext(s.safeCtx(), LogSCACIServerStopped) return nil } @@ -421,7 +427,7 @@ func (s *Server) acceptConnections() { case <-s.shutdown: return // Expected during shutdown default: - s.logger.Error(LogSCACIAcceptConnectionFailed, "error", err) + s.logger.ErrorContext(s.safeCtx(), LogSCACIAcceptConnectionFailed, "error", err) continue } } @@ -510,24 +516,24 @@ func (s *Server) handleConnection(conn net.Conn) { // Extract client certificate from TLS connection tlsConn, ok := conn.(*tls.Conn) if !ok { - s.logger.Error(LogSCACIConnectionNotTLS) + s.logger.ErrorContext(s.safeCtx(), LogSCACIConnectionNotTLS) return } // Force TLS handshake to get client certificate if err := tlsConn.Handshake(); err != nil { - s.logger.Error(LogSCACITLSHandshakeFailed, "error", err) + s.logger.ErrorContext(s.safeCtx(), LogSCACITLSHandshakeFailed, "error", err) return } state := tlsConn.ConnectionState() if len(state.PeerCertificates) == 0 { - s.logger.Error(LogSCACINoClientCertificate) + s.logger.ErrorContext(s.safeCtx(), LogSCACINoClientCertificate) return } clientCert := state.PeerCertificates[0] - s.logger.Info(LogSCACIConnectionEstablished, + s.logger.InfoContext(s.safeCtx(), LogSCACIConnectionEstablished, "remote", conn.RemoteAddr().String(), "cert_cn", clientCert.Subject.CommonName) @@ -547,10 +553,10 @@ func (s *Server) handleConnection(conn net.Conn) { frame, err := s.readFrame(conn) if err != nil { if err == io.EOF { - s.logger.Info(LogSCACIConnectionClosed, + s.logger.InfoContext(s.sessionContext(session), LogSCACIConnectionClosed, "remote", conn.RemoteAddr().String()) } else { - s.logger.Error(LogSCACIReadFrameFailed, "error", err) + s.logger.ErrorContext(s.sessionContext(session), LogSCACIReadFrameFailed, "error", err) } return } @@ -560,7 +566,7 @@ func (s *Server) handleConnection(conn net.Conn) { if err := msgpack.Unmarshal(frame.Payload, &msg); err != nil { // Fallback to JSON decode per SCACI §1 dual-codec support if jsonErr := json.Unmarshal(frame.Payload, &msg); jsonErr != nil { - s.logger.Error(LogSCACIDecodeMessagePackFailed, + s.logger.ErrorContext(s.sessionContext(session), LogSCACIDecodeMessagePackFailed, "msgpack_error", err, "json_error", jsonErr) _ = s.sendErrorWithCatalog(conn, session, 0, POSIX_EINVAL, errInvalidMessageFormat) @@ -571,25 +577,25 @@ func (s *Server) handleConnection(conn net.Conn) { // Extract command and opId (required per §3.2) command, ok := msg["command"].(string) if !ok { - s.logger.Error(LogSCACIMissingCommandField) + s.logger.ErrorContext(s.sessionContext(session), LogSCACIMissingCommandField) _ = s.sendErrorWithCatalog(conn, session, 0, POSIX_EINVAL, errMissingCommandField) continue } opId, ok := normalizeInt64(msg["opId"]) if !ok { - s.logger.Error(LogSCACIMissingOpIDField) + s.logger.ErrorContext(s.sessionContext(session), LogSCACIMissingOpIDField) _ = s.sendErrorWithCatalog(conn, session, 0, POSIX_EINVAL, errMissingOpIdField) continue } - s.logger.Debug(LogSCACIReceivedMessage, + s.logger.DebugContext(s.sessionContext(session), LogSCACIReceivedMessage, "command", command, "opId", opId) // Special handling for Connect (must be first message) if session == nil && command != CmdConnect { - s.logger.Error(LogSCACIFirstMessageMustBeConnect, + s.logger.ErrorContext(s.sessionContext(session), LogSCACIFirstMessageMustBeConnect, "command", command) _ = s.sendErrorWithCatalog(conn, session, opId, POSIX_EINVAL, errConnectRequired) return @@ -597,7 +603,7 @@ func (s *Server) handleConnection(conn net.Conn) { // Route message to handler if err := s.routeMessage(conn, &session, clientCert, command, opId, frame.Payload); err != nil { - s.logger.Error(LogSCACIHandlerError, + s.logger.ErrorContext(s.sessionContext(session), LogSCACIHandlerError, "command", command, "opId", opId, "error", err) @@ -680,7 +686,7 @@ func (s *Server) sendFrame(conn net.Conn, payload []byte) error { // // sendError is the internal transport helper for sendErrorWithCatalog. // All protocol errors should go through sendErrorWithCatalog for catalog consistency. -func (s *Server) sendError(conn net.Conn, _ *Session, opId int64, code int, message string, errorToken *string) error { +func (s *Server) sendError(conn net.Conn, session *Session, opId int64, code int, message string, errorToken *string) error { // Dereference errorToken if not nil (ErrorToken is string, not *string, per spec) tokenStr := "" if errorToken != nil { @@ -699,12 +705,12 @@ func (s *Server) sendError(conn net.Conn, _ *Session, opId int64, code int, mess payload, err := msgpack.Marshal(&errMsg) if err != nil { - s.logger.Error(LogSCACIMarshalErrorFailed, "error", err) + s.logger.ErrorContext(s.sessionContext(session), LogSCACIMarshalErrorFailed, "error", err) return err } if err := s.sendFrame(conn, payload); err != nil { - s.logger.Error(LogSCACISendErrorFailed, "error", err) + s.logger.ErrorContext(s.sessionContext(session), LogSCACISendErrorFailed, "error", err) return err } @@ -717,7 +723,7 @@ func (s *Server) sendError(conn net.Conn, _ *Session, opId int64, code int, mess if errorToken != nil { logFields = append(logFields, "errorToken", *errorToken) } - s.logger.Debug(LogSCACIErrorMessageSent, logFields...) + s.logger.DebugContext(s.sessionContext(session), LogSCACIErrorMessageSent, logFields...) return nil } @@ -739,7 +745,7 @@ func (s *Server) sendError(conn net.Conn, _ *Session, opId int64, code int, mess // Returns: // - error: Send error (logged, not returned to avoid error inception) func (s *Server) sendErrorWithCatalog(conn net.Conn, session *Session, opId int64, defaultCode int, errorToken string, contextDetail ...string) error { - ctx := context.Background() + ctx := s.sessionContext(session) detail := "" if len(contextDetail) > 0 { detail = contextDetail[0] @@ -805,7 +811,7 @@ func (s *Server) sendErrorWithCatalog(conn net.Conn, session *Session, opId int6 // // Returns: // - error: Send error -func (s *Server) sendErrorAck(conn net.Conn, _ *Session, opId int64) error { +func (s *Server) sendErrorAck(conn net.Conn, session *Session, opId int64) error { ack := ErrorAck{ BaseMessage: BaseMessage{ Command: CmdErrorAck, @@ -814,11 +820,11 @@ func (s *Server) sendErrorAck(conn net.Conn, _ *Session, opId int64) error { } if err := s.sendResponse(conn, &ack); err != nil { - s.logger.Error(LogSCACISentErrorAck, "opId", opId, "error", err) + s.logger.ErrorContext(s.sessionContext(session), LogSCACISentErrorAck, "opId", opId, "error", err) return err } - s.logger.Debug(LogSCACISentErrorAck, "opId", opId) + s.logger.DebugContext(s.sessionContext(session), LogSCACISentErrorAck, "opId", opId) return nil } @@ -842,7 +848,7 @@ func (s *Server) sendResponse(conn net.Conn, msg interface{}) error { // Log response for debugging if msgJSON, err := json.Marshal(msg); err == nil { - s.logger.Debug(LogSCACIResponseSent, "message", string(msgJSON)) + s.logger.DebugContext(s.safeCtx(), LogSCACIResponseSent, "message", string(msgJSON)) } return nil @@ -969,7 +975,7 @@ func (s *Server) routeMessage(conn net.Conn, session **Session, cert *x509.Certi // Case 1: Unknown prefix (not in spec allowlist) if _, allowed := allowedSublayerPrefixes[prefix]; !allowed { - s.logger.Warn(LogSCACIUnsupportedSublayerPrefix, logFields...) + s.logger.WarnContext(s.sessionContext(*session), LogSCACIUnsupportedSublayerPrefix, logFields...) return s.sendErrorWithCatalog(conn, sess, opId, POSIX_ENOTSUP, errUnsupportedSublayerPrefix, command) } @@ -978,13 +984,13 @@ func (s *Server) routeMessage(conn net.Conn, session **Session, cert *x509.Certi handler, hasHandler := s.sublayerHandlers[command] if !hasHandler { // No handler registered for this command - s.logger.Warn(LogSCACIUnsupportedSublayerPrefix, logFields...) + s.logger.WarnContext(s.sessionContext(*session), LogSCACIUnsupportedSublayerPrefix, logFields...) return s.sendErrorWithCatalog(conn, sess, opId, POSIX_ENOTSUP, errUnsupportedSublayerPrefix, command) } // Case 3: Handler exists - dispatch directly and return // (bypasses switch to avoid double-error from default case) - s.logger.Debug("SCACI sublayer handler invoked", logFields...) + s.logger.DebugContext(s.sessionContext(*session), LogSCACISublayerHandlerInvoked, logFields...) return handler(conn, sess, opId, payload) } @@ -1026,13 +1032,13 @@ func (s *Server) routeMessage(conn net.Conn, session **Session, cert *x509.Certi return s.handleULDataResponse(conn, *session, opId) case CmdULDataComplete: // AC should never send ulDataCmp - SC sends it per SCACI §3.8.3 - s.logger.Error(LogSCACIUnexpectedULDataCmp, + s.logger.ErrorContext(s.sessionContext(*session), LogSCACIUnexpectedULDataCmp, "opId", opId, "acEui", pkgmioty.FormatEUI64((*session).AcEui)) // Mark operation failed if it exists if (*session).ID > 0 && s.operationRepo != nil { - errCtx, errCancel := context.WithTimeout(context.Background(), dbconfig.DefaultQueryTimeout) + errCtx, errCancel := context.WithTimeout(s.sessionContext(*session), dbconfig.DefaultQueryTimeout) defer errCancel() if err := s.operationRepo.UpdateOperationState(errCtx, (*session).ID, opId, @@ -1040,7 +1046,7 @@ func (s *Server) routeMessage(conn net.Conn, session **Session, cert *x509.Certi "errorToken": errProtocolViolationULCmp, "errorDetail": "AC sent ulDataCmp but SC should send it", }); err != nil { - s.logger.Error(LogSCACIUpdateOperationStateFailed, "error", err) + s.logger.ErrorContext(s.sessionContext(*session), LogSCACIUpdateOperationStateFailed, "error", err) } } @@ -1051,13 +1057,13 @@ func (s *Server) routeMessage(conn net.Conn, session **Session, cert *x509.Certi return s.handleULDataTransmit(conn, *session, opId, payload) case CmdULDataTransmitResponse: // AC cannot send ulDataTxRsp - protocol violation per SCACI §3.9.2 - s.logger.Error(LogSCACIUnexpectedULDataTxRsp, + s.logger.ErrorContext(s.sessionContext(*session), LogSCACIUnexpectedULDataTxRsp, "opId", opId, "acEui", pkgmioty.FormatEUI64((*session).AcEui)) // Mark operation failed if it exists if (*session).ID > 0 && s.operationRepo != nil { - errCtx, errCancel := context.WithTimeout(context.Background(), dbconfig.DefaultQueryTimeout) + errCtx, errCancel := context.WithTimeout(s.sessionContext(*session), dbconfig.DefaultQueryTimeout) defer errCancel() if err := s.operationRepo.UpdateOperationState(errCtx, (*session).ID, opId, @@ -1065,7 +1071,7 @@ func (s *Server) routeMessage(conn net.Conn, session **Session, cert *x509.Certi "errorToken": errProtocolViolationULRsp, "errorDetail": "AC sent ulDataTxRsp but SC should send it", }); err != nil { - s.logger.Error(LogSCACIUpdateOperationStateFailed, "error", err) + s.logger.ErrorContext(s.sessionContext(*session), LogSCACIUpdateOperationStateFailed, "error", err) } } @@ -1104,7 +1110,7 @@ func (s *Server) routeMessage(conn net.Conn, session **Session, cert *x509.Certi return s.handleErrorAck(conn, *session, opId) default: - s.logger.Warn(LogSCACIUnknownCommand, "command", command) + s.logger.WarnContext(s.sessionContext(*session), LogSCACIUnknownCommand, "command", command) return s.sendErrorWithCatalog(conn, *session, opId, POSIX_ENOTSUP, errUnsupportedCommand) } } @@ -1295,7 +1301,7 @@ func derefBool(b *bool) bool { // // Returns: // - error: If any sends fail -func (s *Server) BroadcastULData(_ context.Context, tenantID int64, data *mioty.ULDataMessage) error { +func (s *Server) BroadcastULData(ctx context.Context, tenantID int64, data *mioty.ULDataMessage) error { if data == nil { return fmt.Errorf("nil UL data message") } @@ -1375,7 +1381,7 @@ func (s *Server) BroadcastULData(_ context.Context, tenantID int64, data *mioty. // Record operation after successful send (for resume/audit) if sessionID > 0 && s.operationRepo != nil { - opCtx, opCancel := context.WithTimeout(context.Background(), dbconfig.DefaultQueryTimeout) + opCtx, opCancel := context.WithTimeout(context.WithoutCancel(ctx), dbconfig.DefaultQueryTimeout) requestData := map[string]interface{}{ "epEui": pkgmioty.FormatEUI64(data.EpEui), @@ -1405,7 +1411,7 @@ func (s *Server) BroadcastULData(_ context.Context, tenantID int64, data *mioty. opCancel() // Cancel immediately after operation, not deferred if err != nil { - s.logger.Warn(LogSCACIRecordULDataOpFailed, + s.logger.WarnContext(ctx, LogSCACIRecordULDataOpFailed, "opId", opId, "error", err) // Continue - operation tracking is for resume, not critical path @@ -1425,7 +1431,7 @@ func (s *Server) BroadcastULData(_ context.Context, tenantID int64, data *mioty. // // Returns: // - error: If any sends fail -func (s *Server) BroadcastDLDataResult(_ context.Context, tenantID int64, result *mioty.DLDataResult) error { +func (s *Server) BroadcastDLDataResult(ctx context.Context, tenantID int64, result *mioty.DLDataResult) error { if result == nil { return fmt.Errorf("nil DL data result") } @@ -1508,7 +1514,7 @@ func (s *Server) BroadcastDLDataResult(_ context.Context, tenantID int64, result // Record operation after successful send (for resume/audit) if sessionID > 0 && s.operationRepo != nil { - opCtx, opCancel := context.WithTimeout(context.Background(), dbconfig.DefaultQueryTimeout) + opCtx, opCancel := context.WithTimeout(context.WithoutCancel(ctx), dbconfig.DefaultQueryTimeout) requestData := map[string]interface{}{ "epEui": pkgmioty.FormatEUI64(result.EpEui), @@ -1537,7 +1543,7 @@ func (s *Server) BroadcastDLDataResult(_ context.Context, tenantID int64, result opCancel() // Cancel immediately after operation, not deferred if err != nil { - s.logger.Warn(LogSCACIRecordDLResultOpFailed, + s.logger.WarnContext(ctx, LogSCACIRecordDLResultOpFailed, "opId", opId, "error", err) } @@ -1966,19 +1972,19 @@ func (s *Server) replayEPStatus(conn net.Conn, session *Session, op *models.SCAC // epEui stored as hex string via FormatEUI64 epEuiStr, ok := data["epEui"].(string) if !ok || epEuiStr == "" { - s.logger.Error(LogSCACIReplayEPStatusInvalidData, "opId", op.OpId, "field", "epEui") + s.logger.ErrorContext(s.sessionContext(session), LogSCACIReplayEPStatusInvalidData, "opId", op.OpId, "field", "epEui") return fmt.Errorf("missing/invalid epEui in stored RequestData") } epEui, err := ParseEUI64(epEuiStr) if err != nil { - s.logger.Error(LogSCACIReplayEPStatusInvalidData, "opId", op.OpId, "field", "epEui", "error", err) + s.logger.ErrorContext(s.sessionContext(session), LogSCACIReplayEPStatusInvalidData, "opId", op.OpId, "field", "epEui", "error", err) return fmt.Errorf("parse epEui %q: %w", epEuiStr, err) } // epStatus stored as string enum epStatus, ok := data["epStatus"].(string) if !ok || epStatus == "" { - s.logger.Error(LogSCACIReplayEPStatusInvalidData, "opId", op.OpId, "field", "epStatus") + s.logger.ErrorContext(s.sessionContext(session), LogSCACIReplayEPStatusInvalidData, "opId", op.OpId, "field", "epStatus") return fmt.Errorf("missing/invalid epStatus in stored RequestData") } @@ -2010,7 +2016,7 @@ func (s *Server) replayEPStatus(conn net.Conn, session *Session, op *models.SCAC copy(nonce[:], nonceBytes) msg.Nonce = &nonce } else { - s.logger.Warn(LogSCACIReplayEPStatusFieldDecodeErr, "opId", op.OpId, "field", "nonce") + s.logger.WarnContext(s.sessionContext(session), LogSCACIReplayEPStatusFieldDecodeErr, "opId", op.OpId, "field", "nonce") } } if signStr, ok := data["sign"].(string); ok && signStr != "" { @@ -2020,7 +2026,7 @@ func (s *Server) replayEPStatus(conn net.Conn, session *Session, op *models.SCAC copy(sign[:], signBytes) msg.Sign = &sign } else { - s.logger.Warn(LogSCACIReplayEPStatusFieldDecodeErr, "opId", op.OpId, "field", "sign") + s.logger.WarnContext(s.sessionContext(session), LogSCACIReplayEPStatusFieldDecodeErr, "opId", op.OpId, "field", "sign") } } @@ -2039,7 +2045,7 @@ func (s *Server) replayEPStatus(conn net.Conn, session *Session, op *models.SCAC if subpacketsRaw, ok := data["subpackets"]; ok && subpacketsRaw != nil { subpackets, err := reconstructSubpackets(subpacketsRaw) if err != nil { - s.logger.Warn(LogSCACIReplayEPStatusFieldDecodeErr, "opId", op.OpId, "field", "subpackets", "error", err) + s.logger.WarnContext(s.sessionContext(session), LogSCACIReplayEPStatusFieldDecodeErr, "opId", op.OpId, "field", "subpackets", "error", err) // Continue without subpackets - non-fatal } else { msg.Subpackets = subpackets diff --git a/KC-Core/pkg/scaci/server_replay_test.go b/KC-Core/pkg/scaci/server_replay_test.go index f673937..f678cda 100644 --- a/KC-Core/pkg/scaci/server_replay_test.go +++ b/KC-Core/pkg/scaci/server_replay_test.go @@ -731,7 +731,7 @@ func TestHandleConnect_VersionMismatch_SendsPOSIXEnotsup(t *testing.T) { // Setup mock session validator to pass (Connect field validation OK) mockValidator := new(MockSessionValidator) - mockValidator.On("ValidateConnectFields", mock.Anything, mock.Anything). + mockValidator.On("ValidateConnectFields", mock.Anything). Return("") // Empty = no error // Create server with required dependencies diff --git a/KC-Core/pkg/scaci/server_test.go b/KC-Core/pkg/scaci/server_test.go index 62534f0..752ec2c 100644 --- a/KC-Core/pkg/scaci/server_test.go +++ b/KC-Core/pkg/scaci/server_test.go @@ -168,7 +168,7 @@ func TestSendDLDataResultCompleteCommandMismatch(t *testing.T) { // TestNewServer_NilCfg verifies constructor rejects nil config. func TestNewServer_NilCfg(t *testing.T) { // cfg is first validation - all other params can be nil - _, err := NewServer(nil, testLogger(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(nil, testLogger(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil cfg") assert.Contains(t, err.Error(), "cfg is required") } @@ -177,7 +177,7 @@ func TestNewServer_NilCfg(t *testing.T) { func TestNewServer_NilLogger(t *testing.T) { cfg := &Config{ListenAddr: ":5001"} // logger is second validation - provide valid cfg, nil for rest - _, err := NewServer(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil logger") assert.Contains(t, err.Error(), "logger is required") } @@ -186,7 +186,7 @@ func TestNewServer_NilLogger(t *testing.T) { func TestNewServer_NilSessionRepo(t *testing.T) { cfg := &Config{ListenAddr: ":5001"} // sessionRepo is third validation - _, err := NewServer(cfg, testLogger(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, testLogger(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil sessionRepo") assert.Contains(t, err.Error(), "sessionRepo is required") } @@ -196,7 +196,7 @@ func TestNewServer_NilOperationRepo(t *testing.T) { cfg := &Config{ListenAddr: ":5001"} mockSessionRepo := &mockSessionRepoStub{} // operationRepo is fourth validation - _, err := NewServer(cfg, testLogger(), mockSessionRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, testLogger(), mockSessionRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil operationRepo") assert.Contains(t, err.Error(), "operationRepo is required") } @@ -206,7 +206,7 @@ func TestNewServer_NilHandshakeSvc(t *testing.T) { cfg := &Config{ListenAddr: ":5001"} mockSessionRepo := &mockSessionRepoStub{} mockOpRepo := &mockOperationRepoStub{} - _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil handshakeSvc") assert.Contains(t, err.Error(), "handshakeSvc is required") } @@ -217,7 +217,7 @@ func TestNewServer_NilEndpointSvc(t *testing.T) { mockSessionRepo := &mockSessionRepoStub{} mockOpRepo := &mockOperationRepoStub{} mockHandshake := &MockHandshakeService{} - _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil endpointSvc") assert.Contains(t, err.Error(), "endpointSvc is required") } @@ -229,7 +229,7 @@ func TestNewServer_NilULSvc(t *testing.T) { mockOpRepo := &mockOperationRepoStub{} mockHandshake := &MockHandshakeService{} mockEndpoint := &MockEndpointService{} - _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, mockEndpoint, nil, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, mockEndpoint, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil ulSvc") assert.Contains(t, err.Error(), "ulSvc is required") } @@ -242,7 +242,7 @@ func TestNewServer_NilDLSvc(t *testing.T) { mockHandshake := &MockHandshakeService{} mockEndpoint := &MockEndpointService{} mockUL := &MockULService{} - _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, mockEndpoint, mockUL, nil, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, mockEndpoint, mockUL, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil dlSvc") assert.Contains(t, err.Error(), "dlSvc is required") } @@ -256,7 +256,7 @@ func TestNewServer_NilStatusSvc(t *testing.T) { mockEndpoint := &MockEndpointService{} mockUL := &MockULService{} mockDL := &MockDLService{} - _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, mockEndpoint, mockUL, mockDL, nil, nil, nil, nil, nil, nil, nil) + _, err := NewServer(cfg, testLogger(), mockSessionRepo, mockOpRepo, mockHandshake, mockEndpoint, mockUL, mockDL, nil, nil, nil, nil, nil, nil, nil, nil) require.Error(t, err, "NewServer should reject nil statusSvc") assert.Contains(t, err.Error(), "statusSvc is required") } From c0ca070500a1c1905293285cdcb287c3ea66a086 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 14:42:57 +0200 Subject: [PATCH 09/25] fix(gateway): stop counting client stream teardown as breaker failures The transparent proxy returns codes.Internal (failed proxying s2c) when the CLIENT side of a stream goes away - which happens on every KC-Web page navigation and realtime reconnect. The stream interceptor counted those as upstream failures, so normal browsing opened the core circuit breaker and every page failed with 'circuit breaker is open/recovering'; long-lived streams also pinned the half-open probe slots, sticking recovery. Streams now run outside the breaker's execution slots: an open breaker still rejects them up front, a terminal error still feeds the counters via the new UpstreamBreaker.RecordResult - but only when the client context is still alive, so client-initiated teardown is never a failure. Unary methods keep the original in-breaker execution and per-RPC timeout. --- .../cmd/gateway/breaker_interceptor_test.go | 155 ++++++++++++++++++ KC-Gateway/cmd/gateway/main.go | 50 ++++-- .../internal/resilience/circuit_breaker.go | 10 ++ 3 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 KC-Gateway/cmd/gateway/breaker_interceptor_test.go diff --git a/KC-Gateway/cmd/gateway/breaker_interceptor_test.go b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go new file mode 100644 index 0000000..01366a6 --- /dev/null +++ b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/config" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-Gateway/internal/resilience" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// fakeServerStream carries a controllable context through the interceptor. +type fakeServerStream struct { + ctx context.Context +} + +func (f *fakeServerStream) Context() context.Context { return f.ctx } +func (f *fakeServerStream) SetHeader(metadata.MD) error { return nil } +func (f *fakeServerStream) SendHeader(metadata.MD) error { return nil } +func (f *fakeServerStream) SetTrailer(metadata.MD) {} +func (f *fakeServerStream) SendMsg(_ interface{}) error { return nil } +func (f *fakeServerStream) RecvMsg(_ interface{}) error { return nil } + +func newTestBreakers(threshold uint32) (*resilience.UpstreamBreaker, *resilience.UpstreamBreaker) { + cfg := config.GatewayResilienceConfig{ + CBMaxRequests: 3, + CBInterval: time.Minute, + CBTimeout: time.Minute, + CBFailureThreshold: threshold, + } + return resilience.NewUpstreamBreaker("core", cfg), resilience.NewUpstreamBreaker("identity", cfg) +} + +func streamInfo(method string) *grpc.StreamServerInfo { + return &grpc.StreamServerInfo{FullMethod: method} +} + +// proxyTeardownErr is the shape the transparent proxy returns when the client +// side of a stream goes away. +func proxyTeardownErr() error { + return status.Error(codes.Internal, "failed proxying s2c: context canceled") +} + +// TestBreakerStream_ClientTeardownNeverTrips: the browser closing realtime +// streams (canceled client context + Internal from the proxy) must not open +// the breaker no matter how often it happens. +func TestBreakerStream_ClientTeardownNeverTrips(t *testing.T) { + core, identity := newTestBreakers(3) + interceptor := breakerStreamInterceptor(core, identity, 0) + + canceled, cancel := context.WithCancel(testutil.TestContext()) + cancel() + handler := func(_ interface{}, _ grpc.ServerStream) error { return proxyTeardownErr() } + + for i := 0; i < 10; i++ { + err := interceptor(nil, &fakeServerStream{ctx: canceled}, streamInfo("/kilocenter.api.v1.KiloCenterService/StreamMessages"), handler) + require.Error(t, err, "the handler error is passed through") + } + + assert.Equal(t, resilience.BreakerClosed, core.State(), + "client-initiated stream teardown must never count as an upstream failure") +} + +// TestBreakerStream_UpstreamFailureStillTrips: a stream that dies while the +// client is still connected is a real upstream failure and opens the breaker +// at the threshold. +func TestBreakerStream_UpstreamFailureStillTrips(t *testing.T) { + core, identity := newTestBreakers(3) + interceptor := breakerStreamInterceptor(core, identity, 0) + + handler := func(_ interface{}, _ grpc.ServerStream) error { + return status.Error(codes.Unavailable, "upstream gone") + } + + for i := 0; i < 3; i++ { + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/kilocenter.api.v1.KiloCenterService/StreamMessages"), handler) + } + + assert.Equal(t, resilience.BreakerOpen, core.State(), + "genuine upstream stream failures must still trip the breaker") +} + +// TestBreakerStream_OpenBreakerRejectsStreams: streams respect an open breaker +// without invoking the handler. +func TestBreakerStream_OpenBreakerRejectsStreams(t *testing.T) { + core, identity := newTestBreakers(1) + interceptor := breakerStreamInterceptor(core, identity, 0) + + failing := func(_ interface{}, _ grpc.ServerStream) error { + return status.Error(codes.Unavailable, "upstream gone") + } + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), failing) + require.Equal(t, resilience.BreakerOpen, core.State()) + + handlerCalled := false + err := interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), func(_ interface{}, _ grpc.ServerStream) error { + handlerCalled = true + return nil + }) + + require.Error(t, err) + assert.Equal(t, codes.Unavailable, status.Code(err)) + assert.Contains(t, err.Error(), "circuit breaker is open") + assert.False(t, handlerCalled, "an open breaker must reject before the handler runs") +} + +// TestBreakerStream_SuccessfulStreamRecordsSuccess: clean stream completion +// resets the consecutive-failure count. +func TestBreakerStream_SuccessfulStreamRecordsSuccess(t *testing.T) { + core, identity := newTestBreakers(3) + interceptor := breakerStreamInterceptor(core, identity, 0) + + fail := func(_ interface{}, _ grpc.ServerStream) error { + return status.Error(codes.Unavailable, "blip") + } + ok := func(_ interface{}, _ grpc.ServerStream) error { return nil } + + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), fail) + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), fail) + require.NoError(t, interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), ok)) + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), fail) + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), fail) + + assert.Equal(t, resilience.BreakerClosed, core.State(), + "a successful stream between failures resets the consecutive count") +} + +// TestBreakerStream_UnaryPathCountsFailures: unary methods keep the original +// in-breaker execution (failures count, open breaker rejects with the mapped +// message). +func TestBreakerStream_UnaryPathCountsFailures(t *testing.T) { + core, identity := newTestBreakers(2) + interceptor := breakerStreamInterceptor(core, identity, time.Second) + + unary := "/kilocenter.api.v1.KiloCenterService/ListEndPoints" + require.True(t, unaryMethods[unary], "fixture must use a real unary method") + + fail := func(_ interface{}, _ grpc.ServerStream) error { + return status.Error(codes.Internal, "boom") + } + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo(unary), fail) + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo(unary), fail) + require.Equal(t, resilience.BreakerOpen, core.State()) + + err := interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo(unary), fail) + require.Error(t, err) + assert.Contains(t, err.Error(), "circuit breaker is open") +} diff --git a/KC-Gateway/cmd/gateway/main.go b/KC-Gateway/cmd/gateway/main.go index 6af3812..95f3485 100644 --- a/KC-Gateway/cmd/gateway/main.go +++ b/KC-Gateway/cmd/gateway/main.go @@ -84,25 +84,47 @@ func breakerStreamInterceptor( breaker = identityBreaker } - err := breaker.Execute(func() error { - // Apply per-RPC timeout only for unary methods identified - // by service descriptor. Streaming RPCs (including all proxied - // calls via UnknownServiceHandler) are not timed out. - if rpcTimeout > 0 && unaryMethods[info.FullMethod] { - ctx, cancel := context.WithTimeout(ss.Context(), rpcTimeout) - defer cancel() - ss = &contextServerStream{ServerStream: ss, ctx: ctx} + // Unary methods run inside the breaker with the per-RPC timeout: + // they are short-lived, so occupying an execution slot is correct + // and client cancellations already surface as codes.Canceled. + if unaryMethods[info.FullMethod] { + err := breaker.Execute(func() error { + if rpcTimeout > 0 { + ctx, cancel := context.WithTimeout(ss.Context(), rpcTimeout) + defer cancel() + ss = &contextServerStream{ServerStream: ss, ctx: ctx} + } + return handler(srv, ss) + }) + + // Map gobreaker rejection errors to gRPC Unavailable for client failover + if errors.Is(err, gobreaker.ErrOpenState) { + return status.Error(codes.Unavailable, "upstream circuit breaker is open") } - return handler(srv, ss) - }) + if errors.Is(err, gobreaker.ErrTooManyRequests) { + return status.Error(codes.Unavailable, "upstream circuit breaker is recovering") + } + return err + } - // Map gobreaker rejection errors to gRPC Unavailable for client failover - if errors.Is(err, gobreaker.ErrOpenState) { + // Streaming RPCs (including all proxied calls via + // UnknownServiceHandler) run outside the breaker's execution slots: a + // long-lived stream must not hold a half-open probe slot for its + // whole lifetime. The breaker still gates admission, and the stream's + // terminal error still feeds its counters - except when the client + // tore the stream down (context canceled), which the transparent + // proxy surfaces as codes.Internal "failed proxying s2c" and must not + // count as an upstream failure. + if breaker.State() == resilience.BreakerOpen { return status.Error(codes.Unavailable, "upstream circuit breaker is open") } - if errors.Is(err, gobreaker.ErrTooManyRequests) { - return status.Error(codes.Unavailable, "upstream circuit breaker is recovering") + + err := handler(srv, ss) + if err != nil && ss.Context().Err() != nil { + // Client-initiated teardown: benign, not an upstream failure. + return err } + breaker.RecordResult(err) return err } } diff --git a/KC-Gateway/internal/resilience/circuit_breaker.go b/KC-Gateway/internal/resilience/circuit_breaker.go index 8e75bb9..48e96f7 100644 --- a/KC-Gateway/internal/resilience/circuit_breaker.go +++ b/KC-Gateway/internal/resilience/circuit_breaker.go @@ -66,6 +66,16 @@ func (b *UpstreamBreaker) Execute(fn func() error) error { return err } +// RecordResult feeds a completed call's outcome into the breaker's counters +// without holding an execution slot for the call's duration. Used for +// long-lived streaming RPCs, whose terminal error is classified after the +// stream ends; while the breaker is open the record is a no-op rejection. +func (b *UpstreamBreaker) RecordResult(err error) { + _, _ = b.cb.Execute(func() (any, error) { + return nil, err + }) +} + // State returns the current breaker state. func (b *UpstreamBreaker) State() BreakerState { switch b.cb.State() { From 1d7d9718035e8c8b19ae973278df11dfb92bc45e Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 19:30:10 +0200 Subject: [PATCH 10/25] fix(bssci): sweep pending-op cache on teardown, drop irrecoverable resume rows The pending-operation cache is keyed by the ephemeral runtime session ID, but connection teardown only cleaned it for terminal sessions - an active session lost unexpectedly leaked its cached entries forever, and the inconsistent-resume path swept under a freshly hydrated runtime ID that matched nothing. Teardown now always evicts the dead session's cache via the new StatusService.EvictCachedOperations while the persisted rows keep their resume semantics. The resume reissue loop also removes rows that fail reconstitution (they could never be reissued yet resurfaced on every resume) and stops at the first send failure instead of hammering a broken connection. --- .../internal/grpc/core_service_dldata_test.go | 2 + KC-Core/internal/grpc/core_service_test.go | 2 + .../internal/services/bssci/status_service.go | 9 +- KC-Core/pkg/bssci/contracts.go | 7 + KC-Core/pkg/bssci/resume_reissue_test.go | 200 ++++++++++++++++++ KC-Core/pkg/bssci/server.go | 119 +++++++++-- KC-Core/pkg/bssci/test_helpers_test.go | 24 ++- 7 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 KC-Core/pkg/bssci/resume_reissue_test.go diff --git a/KC-Core/internal/grpc/core_service_dldata_test.go b/KC-Core/internal/grpc/core_service_dldata_test.go index 7676651..2625f37 100644 --- a/KC-Core/internal/grpc/core_service_dldata_test.go +++ b/KC-Core/internal/grpc/core_service_dldata_test.go @@ -312,6 +312,8 @@ func (f *fakeStatusSvc) DeletePendingOperations(_ context.Context, _ *bssci.Sess return 0, nil } +func (f *fakeStatusSvc) EvictCachedOperations(_ *bssci.Session) {} + // fakeDownlinkCmd implements bssci.DownlinkCommander (minimal for SendDownlink tests) type fakeDownlinkCmd struct { lastPacketCnt []int64 // Captures packet counter values sent to base station diff --git a/KC-Core/internal/grpc/core_service_test.go b/KC-Core/internal/grpc/core_service_test.go index 30c2091..12c5805 100644 --- a/KC-Core/internal/grpc/core_service_test.go +++ b/KC-Core/internal/grpc/core_service_test.go @@ -2269,6 +2269,8 @@ func (s *stubStatusSvc) DeletePendingOperations(_ context.Context, _ *bssci.Sess return 0, nil } +func (s *stubStatusSvc) EvictCachedOperations(_ *bssci.Session) {} + type stubDownlinkScheduler struct{} func (s *stubDownlinkScheduler) QueueDownlink(_ context.Context, _ *mioty.DLDataQueue, _ int64) (uint64, uint64, error) { diff --git a/KC-Core/internal/services/bssci/status_service.go b/KC-Core/internal/services/bssci/status_service.go index e1f027d..67594dc 100644 --- a/KC-Core/internal/services/bssci/status_service.go +++ b/KC-Core/internal/services/bssci/status_service.go @@ -248,6 +248,14 @@ func (s *statusService) DeletePendingOperations(ctx context.Context, session *bs if err != nil { return 0, err } + s.EvictCachedOperations(session) + return count, nil +} + +// EvictCachedOperations removes the session's cached operations only; the +// persisted rows stay in place for a later resume. The runtime session ID is +// unreachable after teardown, so unswept entries would leak forever. +func (s *statusService) EvictCachedOperations(session *bssci.Session) { s.mu.Lock() for key := range *s.pendingOps { if key.SessionID == session.ID { @@ -255,7 +263,6 @@ func (s *statusService) DeletePendingOperations(ctx context.Context, session *bs } } s.mu.Unlock() - return count, nil } func (s *statusService) ExtractQueueMetadata(session *bssci.Session, opId int64) (endpointEUI uint64, queueID int64, tenantID string) { diff --git a/KC-Core/pkg/bssci/contracts.go b/KC-Core/pkg/bssci/contracts.go index 77546eb..eacd4e9 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -170,6 +170,13 @@ type StatusService interface { // only after the DB deletion succeeds, evicts the session's cached // operations. A failed deletion leaves the cache untouched. DeletePendingOperations(ctx context.Context, session *Session) (int64, error) + + // EvictCachedOperations removes the session's cached operations without + // touching the persisted rows. Called on every connection teardown: the + // runtime session ID dies with the connection, so its cache entries are + // unreachable afterwards, while the DB rows remain the durable source for + // a later resume. + EvictCachedOperations(session *Session) } // PersistedOperation is a raw persisted pending-operation row returned for diff --git a/KC-Core/pkg/bssci/resume_reissue_test.go b/KC-Core/pkg/bssci/resume_reissue_test.go new file mode 100644 index 0000000..fb00a43 --- /dev/null +++ b/KC-Core/pkg/bssci/resume_reissue_test.go @@ -0,0 +1,200 @@ +package bssci + +import ( + "bytes" + "context" + "net" + "testing" + "time" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/basestation" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/mioty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" +) + +// countingConn is a net.Conn that records started frames (writes beginning +// with the MIOTY frame identifier) and optionally fails every write, standing +// in for a base-station link that breaks mid-reissue. +type countingConn struct { + frames int + failWrite bool +} + +func (c *countingConn) Read(_ []byte) (int, error) { return 0, net.ErrClosed } +func (c *countingConn) Write(b []byte) (int, error) { + if len(b) >= 8 && bytes.Equal(b[:8], mioty.MIOTYFrameIdentifier[:]) { + c.frames++ + } + if c.failWrite { + return 0, net.ErrClosed + } + return len(b), nil +} +func (c *countingConn) Close() error { return nil } +func (c *countingConn) LocalAddr() net.Addr { return &net.TCPAddr{} } +func (c *countingConn) RemoteAddr() net.Addr { return &net.TCPAddr{} } +func (c *countingConn) SetDeadline(_ time.Time) error { return nil } +func (c *countingConn) SetReadDeadline(_ time.Time) error { return nil } +func (c *countingConn) SetWriteDeadline(_ time.Time) error { return nil } + +// newResumeReissueServer assembles a server the same way the interop harness +// does, without a wire transport: connect-complete is invoked directly on a +// hand-built session carrying a resume snapshot. +func newResumeReissueServer(t *testing.T) *Server { + t.Helper() + log := logger.NewNop() + sessionSvc, downlinkSvc, statusSvc, _, broadcaster, queueSerializer, auditLogger, tenantResolver, mockStorage := CreateTestServices(log, nil) + server := NewTestServer(log, mockStorage, nil, 1, + sessionSvc, downlinkSvc, statusSvc, interopConnectionService{}, broadcaster, + queueSerializer, auditLogger, tenantResolver) + server.config = &Config{ + ServiceCenterEUI: TestScEui01, + Vendor: "test-vendor", + Model: "test-model", + Name: "test-sc", + SoftwareVersion: "1.0.0", + MessageEncoding: EncodingJSON, + OperationAckTimeout: 2 * time.Second, + StatusRequestInterval: time.Hour, + } + ctx, cancel := context.WithCancel(testutil.TestContext()) + server.ctx = ctx + server.cancel = cancel + t.Cleanup(cancel) + return server +} + +// newResumeSession builds a session in the awaiting-connect-complete state +// with a resume snapshot attached, as handleConnect leaves it for a resumed +// base station. +func newResumeSession(conn net.Conn, ops []*PendingOperation) *Session { + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "resume-reissue-session", + BaseStationEUI: TestBsEui01, + SessionUUID: make([]byte, 16), + ConnectState: ConnectStateAwaitingConnectComplete, + }, + Conn: conn, + pendingBaseStation: &basestation.BaseStation{ID: 1, TenantID: 1, Name: "Resume BS"}, + } + session.IsResumed = true + session.resumePendingOps = ops + return session +} + +func stopSessionStatus(session *Session) { + session.mu.Lock() + if session.stopStatus != nil { + close(session.stopStatus) + session.stopStatus = nil + } + session.mu.Unlock() +} + +// statusReissueOp is a minimal resumable SC operation (status request) whose +// frame passes outbound validation without reconstitution. +func statusReissueOp(opID int64) *PendingOperation { + return &PendingOperation{ + OperationID: opID, + OperationType: mioty.CmdStatus, + Message: map[string]interface{}{ + "command": mioty.CmdStatus, + "opId": opID, + }, + } +} + +// TestResumeReissueAbortsOnSendFailure: once a reissue send fails the +// connection is broken, so the loop stops instead of attempting the remaining +// operations; their rows stay persisted for the next resume. +func TestResumeReissueAbortsOnSendFailure(t *testing.T) { + server := newResumeReissueServer(t) + conn := &countingConn{failWrite: true} + session := newResumeSession(conn, []*PendingOperation{ + statusReissueOp(-1), + statusReissueOp(-2), + }) + t.Cleanup(func() { stopSessionStatus(session) }) + + msg := &Message{OpId: 0, Command: mioty.CmdConnectComplete} + require.NoError(t, server.handleConnectComplete(server, session, msg, nil)) + + assert.Equal(t, 1, conn.frames, + "the first failed send must abort the reissue loop before the second operation") +} + +// TestResumeRemovesIrrecoverableOperation: an operation whose payload cannot +// be reconstituted is deleted durably so it does not resurface on every +// future resume, and the loop continues with the remaining operations. +func TestResumeRemovesIrrecoverableOperation(t *testing.T) { + server := newResumeReissueServer(t) + statusSvc := server.statusSvc.(*memoryStatusService) + conn := &countingConn{} + corrupt := &PendingOperation{ + OperationID: -3, + OperationType: mioty.CmdULDataTransmit, + Message: map[string]interface{}{"command": mioty.CmdULDataTransmit, "opId": int64(-3)}, + Metadata: map[string]interface{}{"bogus": true}, + } + session := newResumeSession(conn, []*PendingOperation{ + corrupt, + statusReissueOp(-4), + }) + t.Cleanup(func() { stopSessionStatus(session) }) + + msg := &Message{OpId: 0, Command: mioty.CmdConnectComplete} + require.NoError(t, server.handleConnectComplete(server, session, msg, nil)) + + statusSvc.mu.RLock() + removed := append([]int64(nil), statusSvc.removedOps...) + statusSvc.mu.RUnlock() + assert.Equal(t, []int64{-3}, removed, + "the irrecoverable operation's persisted row must be removed") + assert.Equal(t, 1, conn.frames, + "the valid operation after the corrupt one must still be reissued") +} + +// TestTeardownEvictsCacheKeepsRows: when an active session's connection is +// lost, its cached operations are swept (the runtime session ID dies with the +// connection) while the persisted rows are preserved for resume. +func TestTeardownEvictsCacheKeepsRows(t *testing.T) { + h := startInteropServer(t, EncodingJSON) + h.writeFrame(connectPayload(mioty.MIOTYProtocolVersion, uint64(TestBsEui01))) + conRsp := h.readFrame() + require.Equal(t, mioty.CmdConnectResponse, frameCommand(conRsp)) + h.writeFrame(map[string]interface{}{"command": mioty.CmdConnectComplete, "opId": int64(0)}) + h.writeFrame(map[string]interface{}{"command": mioty.CmdPing, "opId": int64(1)}) + require.Equal(t, mioty.CmdPingResponse, frameCommand(h.readFrame())) + + h.server.mu.Lock() + require.Len(t, h.server.sessions, 1, "the activated session must be live") + var live *Session + for _, s := range h.server.sessions { + live = s + } + h.server.mu.Unlock() + + statusSvc := h.server.statusSvc.(*memoryStatusService) + foreign := &Session{ProtocolSessionState: ProtocolSessionState{ID: "other-session"}} + require.NoError(t, statusSvc.RecordPendingOperation(testutil.TestContext(), live, -10, statusReissueOp(-10), live.DbSessionID)) + require.NoError(t, statusSvc.RecordPendingOperation(testutil.TestContext(), foreign, -10, statusReissueOp(-10), 99)) + + require.NoError(t, h.conn.Close()) + <-h.done + + statusSvc.mu.RLock() + _, liveCached := (*statusSvc.pendingOps)[SessionOpKey{SessionID: live.ID, OperationID: -10}] + _, foreignCached := (*statusSvc.pendingOps)[SessionOpKey{SessionID: foreign.ID, OperationID: -10}] + deleteCalls := statusSvc.deleteSessionCalls + statusSvc.mu.RUnlock() + + assert.False(t, liveCached, "teardown must evict the dead session's cached operations") + assert.True(t, foreignCached, "other sessions' cached operations must survive") + assert.Zero(t, deleteCalls, + "an active session's persisted rows must be preserved for resume (no durable delete)") +} diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index d8b4c7c..f90c413 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -115,7 +115,6 @@ type Server struct { downlinkSvc DownlinkService statusSvc StatusService connectionRegistry BaseStationConnectionRegistry - broadcaster SCACIBroadcaster queueSerializer QueueSerializer // Downlink response frame builder auditLogger AuditLogger // Downlink audit event recorder tenantResolver TenantResolver // Queue-to-tenant mapping (replaces queueTenants map) @@ -159,6 +158,10 @@ type Server struct { // dependencies; Start refuses to run without them. runtimeConfigured bool + // started records that Start ran; late runtime reconfiguration of a + // serving instance is rejected. + started bool + // Relay outbox writer for CE mode: enqueues unknown-endpoint uplinks for federation relay relayOutbox RelayOutboxWriter @@ -736,7 +739,6 @@ type Dependencies struct { DownlinkSvc DownlinkService StatusSvc StatusService ConnectionRegistry BaseStationConnectionRegistry - Broadcaster SCACIBroadcaster QueueSerializer QueueSerializer AuditLogger AuditLogger TenantResolver TenantResolver @@ -822,7 +824,6 @@ func NewServer(cfg *Config, log logger.Logger, deps Dependencies) (*Server, erro queueSerializer: deps.QueueSerializer, auditLogger: deps.AuditLogger, tenantResolver: deps.TenantResolver, - broadcaster: deps.Broadcaster, orgResolver: deps.OrgDirectory, defaultTenantID: deps.DefaultTenantID, @@ -850,6 +851,14 @@ func NewServer(cfg *Config, log logger.Logger, deps Dependencies) (*Server, erro // the propagation service and the downlink dispatcher are constructed // against the live *Server and injected back here. func (s *Server) ConfigureRuntime(deps RuntimeDependencies) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return fmt.Errorf("runtime cannot be reconfigured after Start") + } + if s.runtimeConfigured { + return fmt.Errorf("runtime already configured") + } if deps.Propagation == nil { return fmt.Errorf("propagation service is required") } @@ -865,6 +874,50 @@ func (s *Server) ConfigureRuntime(deps RuntimeDependencies) error { return nil } +// validateRuntimeWiring rejects Start on an incompletely wired server: every +// dependency the composition root wires unconditionally must be present, so a +// wiring regression fails at startup instead of as a nil dereference under +// traffic. Feature-controlled collaborators (MQTT, key protection, detach +// validation, federation outbox) stay optional. +func (s *Server) validateRuntimeWiring() error { + required := []struct { + name string + missing bool + }{ + {"session service", s.sessionSvc == nil}, + {"version negotiator", s.versionNegotiator == nil}, + {"downlink service", s.downlinkSvc == nil}, + {"status service", s.statusSvc == nil}, + {"connection registry", s.connectionRegistry == nil}, + {"queue serializer", s.queueSerializer == nil}, + {"audit logger", s.auditLogger == nil}, + {"tenant resolver", s.tenantResolver == nil}, + {"event store", s.eventStore == nil}, + {"base station store", s.basestationRepo == nil}, + {"endpoint directory", s.endpointRepo == nil}, + {"attach persistence", s.attachPersistence == nil}, + {"organization directory", s.orgResolver == nil}, + {"certificate identity resolver", s.certIdentityResolver == nil}, + {"base station directory", s.bsDirectory == nil}, + {"uplink ingest service", s.uplinkIngestSvc == nil}, + {"roaming service", s.roamingSvc == nil}, + {"disposition resolver", s.dispositionResolver == nil}, + {"blueprint decoder", s.blueprintDecoder == nil}, + {"blueprint resolver", s.blueprintResolver == nil}, + {"SCACI endpoint status broadcaster", s.scaciEPStatusBroadcaster == nil}, + {"protocol message store", s.protocolMessages == nil}, + {"DL RX status store", s.dlrxStore == nil}, + {"base station status store", s.bsStatusStore == nil}, + {"downlink queue store", s.downlinkQueueStore == nil}, + } + for _, dep := range required { + if dep.missing { + return fmt.Errorf("%s is required", dep.name) + } + } + return nil +} + // defaultOrgForSessionTenant resolves the default organization for the // session's current tenant (community fallback path); uuid.Nil when // unresolvable. @@ -1018,11 +1071,15 @@ func certsExist(certFile, keyFile, caFile string) bool { func (s *Server) Start() error { // The composition root must supply the circular dependencies before the // server accepts traffic; an incompletely wired server refuses to start. + s.mu.Lock() if !s.runtimeConfigured { + s.mu.Unlock() return fmt.Errorf("server runtime not configured: call ConfigureRuntime before Start") } - if s.uplinkIngestSvc == nil { - return fmt.Errorf("uplink ingest service is required") + s.started = true + s.mu.Unlock() + if err := s.validateRuntimeWiring(); err != nil { + return fmt.Errorf("server wiring incomplete: %w", err) } // The dlRxStatQry expiry sweep runs independently of the TLS listener path. @@ -1347,18 +1404,23 @@ func (s *Server) handleConnection(conn net.Conn) { // Pending operations survive an unexpected loss of an active session: // they are reissued with their original opIds on resume (BSSCI §4/§5.3). // Only a terminal session (rejected, provisional, or terminated above) - // has its rows removed. - if session.DbSessionID != 0 && s.statusSvc != nil && !wasActive { - count, err := s.statusSvc.DeletePendingOperations(ctx, session) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToDeletePendingOperations, - "error", err, - "sessionID", session.DbSessionID) - } else if count > 0 { - s.logger.InfoContext(ctx, LogBSSCIDeletedPendingOperations, - "sessionID", session.DbSessionID, - "count", count) + // has its rows removed. The cache is swept in every case - the runtime + // session ID dies with this connection, so its entries are unreachable + // and a resume re-hydrates from the persisted rows. + if s.statusSvc != nil { + if session.DbSessionID != 0 && !wasActive { + count, err := s.statusSvc.DeletePendingOperations(ctx, session) + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToDeletePendingOperations, + "error", err, + "sessionID", session.DbSessionID) + } else if count > 0 { + s.logger.InfoContext(ctx, LogBSSCIDeletedPendingOperations, + "sessionID", session.DbSessionID, + "count", count) + } } + s.statusSvc.EvictCachedOperations(session) } }() @@ -2250,6 +2312,21 @@ func (s *Server) terminateInconsistentResume(ctx context.Context, previous *Sess return nil } +// removeIrrecoverablePendingOperation deletes the persisted row of a pending +// operation that failed reconstitution during resume. Without the delete the +// corrupt row would be re-read and re-skipped on every future resume. +func (s *Server) removeIrrecoverablePendingOperation(ctx context.Context, session *Session, opID int64) { + if s.statusSvc == nil { + return + } + if err := s.statusSvc.RemovePendingOperation(ctx, session, opID); err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToRemovePendingOperationFromDatabase, + "error", err, + "opId", opID, + "sessionID", session.DbSessionID) + } +} + // handleConnectComplete handles the connect complete operation. The connect // operation is already complete at the protocol level when conCmp arrives, so // failures past this point never send a BSSCI error - partial persistence is @@ -2377,13 +2454,16 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message "opId", op.OperationID, "type", op.OperationType) - // Reconstitute operations BEFORE storing or sending + // Reconstitute operations BEFORE storing or sending. An operation + // that cannot be reconstituted is irrecoverable: its persisted row + // is removed so it does not resurface on every future resume. if op.OperationType == mioty.CmdULDataTransmit && op.Metadata != nil { reconstituted, err := s.reconstitueULDataTxMessage(op.Message, op.Metadata, op) if err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteULDataTxSkipping, "error", err, "opId", op.OperationID) + s.removeIrrecoverablePendingOperation(ctx, session, op.OperationID) continue } op.Message = reconstituted @@ -2394,6 +2474,7 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataQueSkipping, "error", err, "opId", op.OperationID) + s.removeIrrecoverablePendingOperation(ctx, session, op.OperationID) continue } op.Message = reconstituted @@ -2404,6 +2485,7 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataRevSkipping, "error", err, "opId", op.OperationID) + s.removeIrrecoverablePendingOperation(ctx, session, op.OperationID) continue } op.Message = reconstituted @@ -2423,10 +2505,13 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message // Normalize message types to fix JSON float64 conversion before reissuing normalizedMsg := s.normalizeMessageTypes(op.Message, op.OperationType) if err := s.sendMessage(session, normalizedMsg); err != nil { + // A send failure means the connection is broken; the remaining + // operations stay persisted and reissue on the next resume. s.logger.ErrorContext(ctx, LogBSSCIFailedToReissuePendingOperation, "error", err, "opId", op.OperationID, "type", op.OperationType) + break } } } else if session.DbSessionID > 0 { diff --git a/KC-Core/pkg/bssci/test_helpers_test.go b/KC-Core/pkg/bssci/test_helpers_test.go index ab734ad..681e921 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -53,7 +53,7 @@ func NewTestServer( downlinkSvc DownlinkService, statusSvc StatusService, connectionSvc BaseStationConnectionRegistry, - broadcaster SCACIBroadcaster, + _ SCACIBroadcaster, queueSerializer QueueSerializer, auditLogger AuditLogger, tenantResolver TenantResolver, @@ -71,7 +71,6 @@ func NewTestServer( downlinkSvc: downlinkSvc, statusSvc: statusSvc, connectionRegistry: connectionSvc, - broadcaster: broadcaster, queueSerializer: queueSerializer, auditLogger: auditLogger, tenantResolver: tenantResolver, @@ -304,7 +303,7 @@ func NewTestServerWithBaseStationRepo( downlinkSvc DownlinkService, statusSvc StatusService, connectionSvc BaseStationConnectionRegistry, - broadcaster SCACIBroadcaster, + _ SCACIBroadcaster, queueSerializer QueueSerializer, auditLogger AuditLogger, tenantResolver TenantResolver, @@ -322,7 +321,6 @@ func NewTestServerWithBaseStationRepo( downlinkSvc: downlinkSvc, statusSvc: statusSvc, connectionRegistry: connectionSvc, - broadcaster: broadcaster, queueSerializer: queueSerializer, auditLogger: auditLogger, tenantResolver: tenantResolver, @@ -541,6 +539,12 @@ type memoryStatusService struct { // metadataUpdates records UpdatePendingOperationMetadata calls so tests // can assert the persistence path now owned by the StatusService. metadataUpdates []StatusMetadataUpdate + // removedOps records RemovePendingOperation calls (durable deletes) so + // tests can assert irrecoverable rows are removed during resume. + removedOps []int64 + // deleteSessionCalls counts DeletePendingOperations calls so teardown + // tests can assert persisted rows of an active session are preserved. + deleteSessionCalls int } // StatusMetadataUpdate captures one UpdatePendingOperationMetadata call. @@ -606,6 +610,7 @@ func (m *memoryStatusService) GetPendingOperation(session *Session, opId int64) func (m *memoryStatusService) RemovePendingOperation(_ context.Context, session *Session, opId int64) error { m.mu.Lock() defer m.mu.Unlock() + m.removedOps = append(m.removedOps, opId) key := makeSessionOpKey(session, opId) delete(*m.pendingOps, key) return nil @@ -647,6 +652,7 @@ func (m *memoryStatusService) PersistedOperations(_ context.Context, _ int64) ([ func (m *memoryStatusService) DeletePendingOperations(_ context.Context, session *Session) (int64, error) { m.mu.Lock() defer m.mu.Unlock() + m.deleteSessionCalls++ var count int64 for key := range *m.pendingOps { if key.SessionID == session.ID { @@ -657,6 +663,16 @@ func (m *memoryStatusService) DeletePendingOperations(_ context.Context, session return count, nil } +func (m *memoryStatusService) EvictCachedOperations(session *Session) { + m.mu.Lock() + defer m.mu.Unlock() + for key := range *m.pendingOps { + if key.SessionID == session.ID { + delete(*m.pendingOps, key) + } + } +} + func (m *memoryStatusService) ProcessOperationStatusUpdate(_ context.Context, _ *Session, _ int64, _ string) error { return nil } From 5bcb32234624ddf610369880a5074c17699ded1c Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 19:30:10 +0200 Subject: [PATCH 11/25] fix(gateway): admit streams only through a closed circuit breaker Streams run outside the breaker's execution slots, so the previous gate (reject only when open) let a half-open breaker admit unlimited streams alongside its slot-accounted unary probes, bypassing MaxRequests and muddying the recovery verdict. Streams now require a closed breaker; half-open returns the recovering message and recovery is proven by the unary probe path alone. --- .../cmd/gateway/breaker_interceptor_test.go | 48 +++++++++++++++++++ KC-Gateway/cmd/gateway/main.go | 12 +++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/KC-Gateway/cmd/gateway/breaker_interceptor_test.go b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go index 01366a6..cb6c015 100644 --- a/KC-Gateway/cmd/gateway/breaker_interceptor_test.go +++ b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go @@ -111,6 +111,54 @@ func TestBreakerStream_OpenBreakerRejectsStreams(t *testing.T) { assert.False(t, handlerCalled, "an open breaker must reject before the handler runs") } +// TestBreakerStream_HalfOpenRejectsStreams: a half-open breaker admits only +// slot-accounted unary probes; streams are rejected with the recovering +// message until the probes close the breaker again. +func TestBreakerStream_HalfOpenRejectsStreams(t *testing.T) { + cfg := config.GatewayResilienceConfig{ + CBMaxRequests: 3, + CBInterval: time.Minute, + CBTimeout: 50 * time.Millisecond, + CBFailureThreshold: 1, + } + core := resilience.NewUpstreamBreaker("core", cfg) + identity := resilience.NewUpstreamBreaker("identity", cfg) + interceptor := breakerStreamInterceptor(core, identity, 0) + + fail := func(_ interface{}, _ grpc.ServerStream) error { + return status.Error(codes.Unavailable, "upstream gone") + } + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), fail) + require.Equal(t, resilience.BreakerOpen, core.State()) + + // After CBTimeout the breaker transitions to half-open on next inspection + time.Sleep(60 * time.Millisecond) + require.Equal(t, resilience.BreakerHalfOpen, core.State()) + + handlerCalled := false + err := interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), func(_ interface{}, _ grpc.ServerStream) error { + handlerCalled = true + return nil + }) + require.Error(t, err) + assert.Equal(t, codes.Unavailable, status.Code(err)) + assert.Contains(t, err.Error(), "circuit breaker is recovering") + assert.False(t, handlerCalled, "a half-open breaker must not admit streams") + + // Successful unary probes close the breaker (CBMaxRequests consecutive + // successes), after which streams flow again. + unary := "/kilocenter.api.v1.KiloCenterService/ListEndPoints" + require.True(t, unaryMethods[unary], "fixture must use a real unary method") + ok := func(_ interface{}, _ grpc.ServerStream) error { return nil } + for i := 0; i < int(cfg.CBMaxRequests); i++ { + require.NoError(t, interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo(unary), ok)) + } + require.Equal(t, resilience.BreakerClosed, core.State()) + + require.NoError(t, interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), ok), + "streams must be admitted again once the probes closed the breaker") +} + // TestBreakerStream_SuccessfulStreamRecordsSuccess: clean stream completion // resets the consecutive-failure count. func TestBreakerStream_SuccessfulStreamRecordsSuccess(t *testing.T) { diff --git a/KC-Gateway/cmd/gateway/main.go b/KC-Gateway/cmd/gateway/main.go index 95f3485..2581af3 100644 --- a/KC-Gateway/cmd/gateway/main.go +++ b/KC-Gateway/cmd/gateway/main.go @@ -110,13 +110,19 @@ func breakerStreamInterceptor( // Streaming RPCs (including all proxied calls via // UnknownServiceHandler) run outside the breaker's execution slots: a // long-lived stream must not hold a half-open probe slot for its - // whole lifetime. The breaker still gates admission, and the stream's - // terminal error still feeds its counters - except when the client + // whole lifetime. Streams are therefore admitted only while the + // breaker is closed - a half-open breaker probes recovery through the + // slot-accounted unary path, and unbounded streams admitted beside + // those probes would bypass MaxRequests entirely. The stream's + // terminal error still feeds the counters - except when the client // tore the stream down (context canceled), which the transparent // proxy surfaces as codes.Internal "failed proxying s2c" and must not // count as an upstream failure. - if breaker.State() == resilience.BreakerOpen { + switch breaker.State() { + case resilience.BreakerOpen: return status.Error(codes.Unavailable, "upstream circuit breaker is open") + case resilience.BreakerHalfOpen: + return status.Error(codes.Unavailable, "upstream circuit breaker is recovering") } err := handler(srv, ss) From 78bc6ada0b1f1a7ef330a76e8a8c6c1ffdcaf0e0 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 19:30:10 +0200 Subject: [PATCH 12/25] fix(bssci,scaci): fail-fast wiring validation and runtime lifecycle guards Start now verifies every dependency the composition root wires unconditionally, so a wiring regression fails at startup instead of as a nil dereference under traffic; feature-controlled collaborators stay optional. ConfigureRuntime rejects double configuration and any call after Start. The SCACI constructor gains the same fail-fast checks for its four previously unvalidated collaborators (org resolver, session snapshot provider, propagation, error recorder). Removes the dead Server.broadcaster field and its Dependencies entry: SCACI broadcasting reaches consumers through the service bundle, the server never read it. --- KC-Core/cmd/kilocenter/builders/protocol.go | 1 - KC-Core/pkg/bssci/dl_rx_status_test.go | 3 +- KC-Core/pkg/bssci/runtime_wiring_test.go | 74 +++++++++++++++++++++ KC-Core/pkg/scaci/server.go | 12 ++++ 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 KC-Core/pkg/bssci/runtime_wiring_test.go diff --git a/KC-Core/cmd/kilocenter/builders/protocol.go b/KC-Core/cmd/kilocenter/builders/protocol.go index 8bc8dc6..9d6979d 100644 --- a/KC-Core/cmd/kilocenter/builders/protocol.go +++ b/KC-Core/cmd/kilocenter/builders/protocol.go @@ -215,7 +215,6 @@ func BuildProtocolServers(ctx context.Context, infra *Infrastructure) (*Protocol DownlinkSvc: bssciSvcBundle.DownlinkSvc, StatusSvc: bssciSvcBundle.StatusSvc, ConnectionRegistry: bssciSvcBundle.ConnectionSvc, - Broadcaster: bssciSvcBundle.Broadcaster, QueueSerializer: bssciSvcBundle.QueueSerializer, AuditLogger: bssciSvcBundle.AuditLogger, TenantResolver: bssciSvcBundle.TenantResolver, diff --git a/KC-Core/pkg/bssci/dl_rx_status_test.go b/KC-Core/pkg/bssci/dl_rx_status_test.go index e0e8190..22a68c6 100644 --- a/KC-Core/pkg/bssci/dl_rx_status_test.go +++ b/KC-Core/pkg/bssci/dl_rx_status_test.go @@ -162,7 +162,7 @@ func TestDLRXStatusPersistence(t *testing.T) { func TestHandlerRegistration(t *testing.T) { // Create real services for test logger := logger.NewNop() - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(logger, nil) + sessionSvc, downlinkSvc, statusSvc, connectionSvc, _, queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(logger, nil) versionNegotiator, err := bssciservices.NewVersionNegotiator([]string{mioty.MIOTYProtocolVersion}, logger) require.NoError(t, err, "NewVersionNegotiator should build from the canonical version") server, err := bssci.NewServer(&bssci.Config{}, logger, bssci.Dependencies{ @@ -171,7 +171,6 @@ func TestHandlerRegistration(t *testing.T) { DownlinkSvc: downlinkSvc, StatusSvc: statusSvc, ConnectionRegistry: connectionSvc, - Broadcaster: broadcaster, QueueSerializer: queueSerializer, AuditLogger: auditLogger, TenantResolver: tenantResolver, diff --git a/KC-Core/pkg/bssci/runtime_wiring_test.go b/KC-Core/pkg/bssci/runtime_wiring_test.go new file mode 100644 index 0000000..21395cd --- /dev/null +++ b/KC-Core/pkg/bssci/runtime_wiring_test.go @@ -0,0 +1,74 @@ +package bssci + +import ( + "context" + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage/models" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type noopPropagationService struct{} + +func (noopPropagationService) TriggerEndpointPropagate(_ context.Context, _ int64, _ []propagation.BaseStationSession) error { + return nil +} + +func (noopPropagationService) ReconcileBaseStation(_ context.Context, _ propagation.BaseStationSession, _ *models.BaseStation) error { + return nil +} + +type noopDownlinkDispatcher struct{} + +func (noopDownlinkDispatcher) DispatchIfAvailable(_ context.Context, _ int64, _ uuid.UUID, _ *Session, _ uint64, _, _ bool) (bool, error) { + return false, nil +} + +func (noopDownlinkDispatcher) DispatchQueue(_ context.Context, _ int64, _ uuid.UUID, _ *Session, _, _ uint64) (bool, error) { + return false, nil +} + +func newRuntimeDeps() RuntimeDependencies { + return RuntimeDependencies{ + Propagation: noopPropagationService{}, + DownlinkDispatcher: noopDownlinkDispatcher{}, + } +} + +// TestConfigureRuntimeRejectsDoubleCall: the circular dependencies are wired +// exactly once by the composition root. +func TestConfigureRuntimeRejectsDoubleCall(t *testing.T) { + server := newResumeReissueServer(t) + require.NoError(t, server.ConfigureRuntime(newRuntimeDeps())) + + err := server.ConfigureRuntime(newRuntimeDeps()) + require.Error(t, err) + assert.Contains(t, err.Error(), "already configured") +} + +// TestConfigureRuntimeRejectedAfterStart: a serving instance cannot be +// reconfigured. +func TestConfigureRuntimeRejectedAfterStart(t *testing.T) { + server := newResumeReissueServer(t) + server.mu.Lock() + server.started = true + server.mu.Unlock() + + err := server.ConfigureRuntime(newRuntimeDeps()) + require.Error(t, err) + assert.Contains(t, err.Error(), "after Start") +} + +// TestValidateRuntimeWiringReportsMissingDependency: an incompletely wired +// server is rejected at startup instead of failing as a nil dereference under +// traffic. The test harness intentionally omits several production-mandatory +// dependencies, so validation must fail and name one of them. +func TestValidateRuntimeWiringReportsMissingDependency(t *testing.T) { + server := newResumeReissueServer(t) + err := server.validateRuntimeWiring() + require.Error(t, err, "a partially wired server must not pass wiring validation") + assert.Contains(t, err.Error(), "is required") +} diff --git a/KC-Core/pkg/scaci/server.go b/KC-Core/pkg/scaci/server.go index bc3c46c..d91def5 100644 --- a/KC-Core/pkg/scaci/server.go +++ b/KC-Core/pkg/scaci/server.go @@ -240,6 +240,18 @@ func NewServer( if sessionPersistence == nil { return nil, fmt.Errorf("scaci.NewServer: sessionPersistence is required") } + if orgResolver == nil { + return nil, fmt.Errorf("scaci.NewServer: orgResolver is required (org context parity)") + } + if sessionSnapshotProvider == nil { + return nil, fmt.Errorf("scaci.NewServer: sessionSnapshotProvider is required (§3.3)") + } + if propagationSvc == nil { + return nil, fmt.Errorf("scaci.NewServer: propagationSvc is required (§3.6-3.7)") + } + if errorRecorder == nil { + return nil, fmt.Errorf("scaci.NewServer: errorRecorder is required (§3.14)") + } return &Server{ config: cfg, From ade13abc383d493181dc1b2cbe9ac0a0c8d84fdf Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 19:30:10 +0200 Subject: [PATCH 13/25] refactor(scaci): rename PersistConnectAsync to PersistResumeAsync, drop dead create branch Fresh sessions are persisted synchronously via PersistConnectSync before operation logging, so the async path's create branch was unreachable - and it wrote session.ID from the goroutine, a latent data race on the live session. The renamed PersistResumeAsync handles only the resume update, reads nothing but the immutable pre-goroutine snapshot, and rejects misuse on a fresh session. Fresh-session persistence coverage moves to PersistConnectSync tests. --- .../services/scaci/session_persistence.go | 177 ++++++------------ .../scaci/session_persistence_test.go | 151 ++++++++------- KC-Core/pkg/scaci/contracts.go | 31 ++- KC-Core/pkg/scaci/handler_connect.go | 20 +- KC-Core/pkg/scaci/mocks_test.go | 8 +- 5 files changed, 165 insertions(+), 222 deletions(-) diff --git a/KC-Core/internal/services/scaci/session_persistence.go b/KC-Core/internal/services/scaci/session_persistence.go index 8da7986..6d54065 100644 --- a/KC-Core/internal/services/scaci/session_persistence.go +++ b/KC-Core/internal/services/scaci/session_persistence.go @@ -33,135 +33,78 @@ func NewSessionPersistence( } // connectSnapshot is the immutable copy of every session field the async -// persistence goroutine reads. Capturing it before the goroutine starts -// prevents races with caller mutations of the live session; all fields are -// value types, and metadata is deep-copied. +// resume-persistence goroutine reads. Capturing it before the goroutine +// starts prevents races with caller mutations of the live session; all +// fields are value types, and metadata is deep-copied. type connectSnapshot struct { - Resumed bool - ID int64 - TenantID int64 - OrganizationID uuid.UUID - AcEuiBytes [8]byte - SnAcUUID scaci.UUID16 - SnScUUID scaci.UUID16 - AcOpIdCounter int64 - ScOpIdCounter int64 - Metadata map[string]interface{} + Resumed bool + ID int64 + TenantID int64 + AcOpIdCounter int64 + ScOpIdCounter int64 + Metadata map[string]interface{} } -// PersistConnectAsync handles async session creation/update after Connect handshake. -// Encapsulates the persistence pattern in a service (handlers orchestrate, services execute). -// The caller's ctx contributes tenant/org log fields only: the goroutine detaches from -// its cancellation so persistence outlives the connection that triggered it. -func (p *sessionPersistence) PersistConnectAsync(ctx context.Context, session *scaci.Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) { - // CRITICAL: snapshot BEFORE the goroutine to prevent races with caller - // mutations; metadata is deep-copied exactly once. +// PersistResumeAsync updates the persisted row of a resumed session after +// the Connect handshake (heartbeat, status, TLS evidence, counters, +// metadata). Fresh sessions are persisted synchronously via +// PersistConnectSync, so this path never creates rows and never mutates the +// live session: the goroutine reads only the immutable snapshot taken before +// it starts. The caller's ctx contributes tenant/org log fields only; the +// goroutine detaches from its cancellation so persistence outlives the +// connection that triggered it. +func (p *sessionPersistence) PersistResumeAsync(ctx context.Context, session *scaci.Session, tlsVersion, cipherSuite string) { + // Snapshot BEFORE the goroutine to prevent races with caller mutations; + // metadata is deep-copied exactly once. snap := connectSnapshot{ - Resumed: session.Resumed, - ID: session.ID, - TenantID: session.TenantID, - OrganizationID: session.OrganizationID, - AcEuiBytes: session.GetAcEuiBytes(), - SnAcUUID: session.SnAcUUID, - SnScUUID: session.SnScUUID, - AcOpIdCounter: session.AcOpIdCounter, - ScOpIdCounter: session.ScOpIdCounter, - Metadata: deepCopyMetadata(session.Metadata), + Resumed: session.Resumed, + ID: session.ID, + TenantID: session.TenantID, + AcOpIdCounter: session.AcOpIdCounter, + ScOpIdCounter: session.ScOpIdCounter, + Metadata: deepCopyMetadata(session.Metadata), + } + + if !snap.Resumed || snap.ID <= 0 { + p.logger.ErrorContext(ctx, scaci.LogSCACIPersistSessionFailed, + "error", "PersistResumeAsync requires a resumed session with a persisted ID") + return } - metadataCopy := snap.Metadata go func() { ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), scaci.ConnectPersistTimeout) defer cancel() - if snap.Resumed && snap.ID > 0 { - // Update existing session (resumed connection) - now := time.Now() - status := "active" - - // Prepare TLS evidence pointers for resume per SCACI §1 - var tlsVer, cipherSt *string - if tlsVersion != "" { - tlsVer = &tlsVersion - } - if cipherSuite != "" { - cipherSt = &cipherSuite - } - - updateReq := &models.SCACISessionUpdateRequest{ - LastHeartbeat: &now, - Status: &status, - TLSVersion: tlsVer, // Update TLS evidence on resume - CipherSuite: cipherSt, // Update cipher suite on resume - Metadata: metadataCopy, // Use pre-copied metadata (no re-copy) - } - - // Only persist opId counters if they're non-zero (preserve "opId 0 after connect") - if snap.AcOpIdCounter > 0 { - updateReq.LastOpIDAc = &snap.AcOpIdCounter - } - if snap.ScOpIdCounter < 0 { - updateReq.LastOpIDSc = &snap.ScOpIdCounter - } + now := time.Now() + status := "active" - if err := p.sessionRepo.UpdateSession(ctx, snap.TenantID, snap.ID, updateReq); err != nil { - p.logger.ErrorContext(ctx, scaci.LogSCACIUpdateSessionFailed, "error", err) - } - } else { - // Create new session (fresh connection) - var certFP, certSubj, remAddr, tlsVer, cipherSt *string - if certFingerprint != "" { - certFP = &certFingerprint - } - if certSubject != "" { - certSubj = &certSubject - } - if remoteAddr != "" { - remAddr = &remoteAddr - } - // TLS evidence per SCACI §1 - if tlsVersion != "" { - tlsVer = &tlsVersion - } - if cipherSuite != "" { - cipherSt = &cipherSuite - } - - // Set organization ID if not nil - var orgID *uuid.UUID - if snap.OrganizationID != uuid.Nil { - orgID = &snap.OrganizationID - } + // TLS evidence pointers for resume per SCACI §1 + var tlsVer, cipherSt *string + if tlsVersion != "" { + tlsVer = &tlsVersion + } + if cipherSuite != "" { + cipherSt = &cipherSuite + } - // Set negotiated version - defaults to ProtocolVersionString if empty - negVer := negotiatedVersion - if negVer == "" { - negVer = scaci.ProtocolVersionString - } + updateReq := &models.SCACISessionUpdateRequest{ + LastHeartbeat: &now, + Status: &status, + TLSVersion: tlsVer, + CipherSuite: cipherSt, + Metadata: snap.Metadata, + } - createReq := &models.SCACISessionCreateRequest{ - TenantID: snap.TenantID, - OrganizationID: orgID, - AcEUI: snap.AcEuiBytes, - SnAcUUID: snap.SnAcUUID, - SnScUUID: snap.SnScUUID, - CertificateFingerprint: certFP, - ClientCertSubject: certSubj, - RemoteAddr: remAddr, - TLSVersion: tlsVer, - CipherSuite: cipherSt, - NegotiatedVersion: negVer, // SCACI §§2.1-2.3: persist for resume validation - CanResume: true, - Metadata: metadataCopy, // Use pre-copied metadata (no re-copy) - } + // Only persist opId counters if they're non-zero (preserve "opId 0 after connect") + if snap.AcOpIdCounter > 0 { + updateReq.LastOpIDAc = &snap.AcOpIdCounter + } + if snap.ScOpIdCounter < 0 { + updateReq.LastOpIDSc = &snap.ScOpIdCounter + } - if dbSession, err := p.sessionRepo.CreateSession(ctx, createReq); err != nil { - p.logger.ErrorContext(ctx, scaci.LogSCACIPersistSessionFailed, "error", err) - } else { - // Store DB ID in runtime session - // Note: This updates the session object passed by reference - session.ID = dbSession.ID - } + if err := p.sessionRepo.UpdateSession(ctx, snap.TenantID, snap.ID, updateReq); err != nil { + p.logger.ErrorContext(ctx, scaci.LogSCACIUpdateSessionFailed, "error", err) } }() } @@ -170,12 +113,12 @@ func (p *sessionPersistence) PersistConnectAsync(ctx context.Context, session *s // Used for fresh connects only - ensures session.ID is assigned BEFORE operation logging // so that Connect audit rows have real session IDs (SCACI §3.3-04 audit trail). // -// Resumed sessions continue using PersistConnectAsync (they already have session.ID > 0). +// Resumed sessions use PersistResumeAsync (they already have session.ID > 0). func (p *sessionPersistence) PersistConnectSync(ctx context.Context, session *scaci.Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) (int64, error) { createCtx, cancel := context.WithTimeout(ctx, scaci.ConnectPersistTimeout) defer cancel() - // Reuse exact model construction from PersistConnectAsync for single source of truth + // Pointer-field construction for the create request var certFP, certSubj, remAddr, tlsVer, cipherSt *string if certFingerprint != "" { certFP = &certFingerprint diff --git a/KC-Core/internal/services/scaci/session_persistence_test.go b/KC-Core/internal/services/scaci/session_persistence_test.go index 4e8f7c9..4ab8fe3 100644 --- a/KC-Core/internal/services/scaci/session_persistence_test.go +++ b/KC-Core/internal/services/scaci/session_persistence_test.go @@ -195,10 +195,9 @@ func (m *mockSessionRepoForPersistence) CleanupExpiredSessions(_ context.Context // Organization ID Persistence Tests (P0) // ============================================================================ -// TestPersistConnectAsync_FreshSession_WritesOrganizationID validates that -// fresh sessions have organization_id persisted to the database. -// Ref: session_persistence.go:98-102 (orgID population), line 112 (CreateRequest field) -func TestPersistConnectAsync_FreshSession_WritesOrganizationID(t *testing.T) { +// TestPersistConnectSync_WritesOrganizationID validates that fresh sessions +// have organization_id persisted to the database via the sync path. +func TestPersistConnectSync_WritesOrganizationID(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -214,12 +213,15 @@ func TestPersistConnectAsync_FreshSession_WritesOrganizationID(t *testing.T) { Resumed: false, // Fresh session } - // Call persistence (async) - svc.PersistConnectAsync(testutil.TestContext(), session, "cert-fingerprint", "CN=test", "192.168.1.1:5001", + // The mock signals on an unbuffered channel; consume it concurrently so + // the synchronous call does not deadlock. + done := make(chan struct{}) + go func() { mockRepo.waitForCompletion(); close(done) }() + id, err := svc.PersistConnectSync(testutil.TestContext(), session, "cert-fingerprint", "CN=test", "192.168.1.1:5001", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) - - // Wait for async goroutine to complete (deterministic) - mockRepo.waitForCompletion() + <-done + require.NoError(t, err) + assert.Equal(t, int64(123), id, "sync persistence returns the DB-assigned session ID") // Assert CreateSession was called require.True(t, mockRepo.wasCreateCalled(), "CreateSession should be called for fresh session") @@ -232,9 +234,9 @@ func TestPersistConnectAsync_FreshSession_WritesOrganizationID(t *testing.T) { assert.Equal(t, int64(42), createReq.TenantID, "TenantID should be preserved") } -// TestPersistConnectAsync_FreshSession_NilOrgID validates that nil org ID -// is handled correctly (community mode / no org resolution). -func TestPersistConnectAsync_FreshSession_NilOrgID(t *testing.T) { +// TestPersistConnectSync_NilOrgID validates that nil org ID is handled +// correctly (community mode / no org resolution). +func TestPersistConnectSync_NilOrgID(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -249,8 +251,11 @@ func TestPersistConnectAsync_FreshSession_NilOrgID(t *testing.T) { Resumed: false, } - svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) - mockRepo.waitForCompletion() + done := make(chan struct{}) + go func() { mockRepo.waitForCompletion(); close(done) }() + _, err := svc.PersistConnectSync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) + <-done + require.NoError(t, err) require.True(t, mockRepo.wasCreateCalled()) createReq := mockRepo.getLastCreateReq() @@ -258,10 +263,9 @@ func TestPersistConnectAsync_FreshSession_NilOrgID(t *testing.T) { assert.Nil(t, createReq.OrganizationID, "OrganizationID should be nil when session.OrganizationID is uuid.Nil") } -// TestPersistConnectAsync_ResumedSession_PreservesOrgID validates that resumed -// sessions do NOT override organization_id (it's preserved from original session). -// Ref: session_persistence.go:45-77 (resume path - UpdateSession, not CreateSession) -func TestPersistConnectAsync_ResumedSession_PreservesOrgID(t *testing.T) { +// TestPersistResumeAsync_PreservesOrgID validates that resumed sessions do +// NOT override organization_id (it's preserved from the original session row). +func TestPersistResumeAsync_PreservesOrgID(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -277,7 +281,7 @@ func TestPersistConnectAsync_ResumedSession_PreservesOrgID(t *testing.T) { Resumed: true, // Resumed session } - svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) + svc.PersistResumeAsync(testutil.TestContext(), session, "TLS 1.3", "TLS_AES_256_GCM_SHA384") mockRepo.waitForCompletion() // Assert UpdateSession was called (not CreateSession) @@ -297,10 +301,9 @@ func TestPersistConnectAsync_ResumedSession_PreservesOrgID(t *testing.T) { // TLS Evidence Tests (P1) // ============================================================================ -// TestPersistConnectAsync_FreshSession_WritesTLSEvidence validates that -// TLS version, cipher suite, and certificate fingerprint are persisted. -// Ref: session_persistence.go:81-96 (TLS evidence population) -func TestPersistConnectAsync_FreshSession_WritesTLSEvidence(t *testing.T) { +// TestPersistConnectSync_WritesTLSEvidence validates that TLS version, +// cipher suite, and certificate fingerprint are persisted. +func TestPersistConnectSync_WritesTLSEvidence(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -317,9 +320,12 @@ func TestPersistConnectAsync_FreshSession_WritesTLSEvidence(t *testing.T) { tlsVersion := "TLS 1.3" cipherSuite := "TLS_AES_256_GCM_SHA384" - svc.PersistConnectAsync(testutil.TestContext(), session, certFingerprint, "CN=test-ac", "10.0.0.1:5001", + done := make(chan struct{}) + go func() { mockRepo.waitForCompletion(); close(done) }() + _, err := svc.PersistConnectSync(testutil.TestContext(), session, certFingerprint, "CN=test-ac", "10.0.0.1:5001", tlsVersion, cipherSuite, scaci.ProtocolVersionString) - mockRepo.waitForCompletion() + <-done + require.NoError(t, err) require.True(t, mockRepo.wasCreateCalled()) createReq := mockRepo.getLastCreateReq() @@ -342,10 +348,9 @@ func TestPersistConnectAsync_FreshSession_WritesTLSEvidence(t *testing.T) { assert.Equal(t, "10.0.0.1:5001", *createReq.RemoteAddr) } -// TestPersistConnectAsync_ResumedSession_UpdatesTLSEvidence validates that -// TLS evidence is updated on session resume (new TLS handshake). -// Ref: session_persistence.go:50-64 (TLS update on resume) -func TestPersistConnectAsync_ResumedSession_UpdatesTLSEvidence(t *testing.T) { +// TestPersistResumeAsync_UpdatesTLSEvidence validates that TLS evidence is +// updated on session resume (new TLS handshake). +func TestPersistResumeAsync_UpdatesTLSEvidence(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -359,7 +364,7 @@ func TestPersistConnectAsync_ResumedSession_UpdatesTLSEvidence(t *testing.T) { newTLSVersion := "TLS 1.3" newCipherSuite := "TLS_CHACHA20_POLY1305_SHA256" - svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", newTLSVersion, newCipherSuite, scaci.ProtocolVersionString) + svc.PersistResumeAsync(testutil.TestContext(), session, newTLSVersion, newCipherSuite) mockRepo.waitForCompletion() require.True(t, mockRepo.wasUpdateCalled()) @@ -374,19 +379,17 @@ func TestPersistConnectAsync_ResumedSession_UpdatesTLSEvidence(t *testing.T) { assert.Equal(t, newCipherSuite, *updateReq.CipherSuite) } -// TestPersistConnectAsync_UsesConnectPersistTimeout validates that -// persistence uses ConnectPersistTimeout constant (5 seconds per constants.go). -// This test verifies the timeout doesn't block the handler flow. -// Ref: session_persistence.go:42 (context.WithTimeout) -func TestPersistConnectAsync_UsesConnectPersistTimeout(t *testing.T) { +// TestConnectPersistTimeoutConstant validates the ConnectPersistTimeout +// constant bounding both persistence paths (5 seconds per constants.go). +func TestConnectPersistTimeoutConstant(t *testing.T) { // Verify the timeout constant exists and has expected value assert.Equal(t, 5*time.Second, scaci.ConnectPersistTimeout, "ConnectPersistTimeout should be 5 seconds per constants.go") } -// TestPersistConnectAsync_NonBlocking validates that PersistConnectAsync +// TestPersistResumeAsync_NonBlocking validates that PersistResumeAsync // returns immediately without waiting for persistence to complete. -func TestPersistConnectAsync_NonBlocking(t *testing.T) { +func TestPersistResumeAsync_NonBlocking(t *testing.T) { log := logger.NewNop() // Create a slow mock that takes 500ms @@ -394,30 +397,42 @@ func TestPersistConnectAsync_NonBlocking(t *testing.T) { svc := NewSessionPersistence(slowMock, log) session := &scaci.Session{ + ID: 999, TenantID: 1, - AcEui: 0xAABBCCDDEEFF1122, - SnAcUUID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}, - SnScUUID: [16]byte{0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20}, - Resumed: false, + Resumed: true, } start := time.Now() - svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) + svc.PersistResumeAsync(testutil.TestContext(), session, "TLS 1.3", "TLS_AES_256_GCM_SHA384") elapsed := time.Since(start) // Should return almost immediately (not wait for 500ms mock delay) assert.Less(t, elapsed, 50*time.Millisecond, - "PersistConnectAsync should return immediately, not block for persistence") + "PersistResumeAsync should return immediately, not block for persistence") // Wait for async to complete deterministically slowMock.waitForCompletion() - assert.True(t, slowMock.wasCreateCalled(), "CreateSession should eventually be called") + assert.True(t, slowMock.wasUpdateCalled(), "UpdateSession should eventually be called") } -// TestPersistConnectAsync_NegotiatedVersionDefault validates that +// TestPersistResumeAsync_RejectsFreshSession validates the resume-only +// contract: a session without a persisted ID is rejected without touching +// the repository (fresh sessions go through PersistConnectSync). +func TestPersistResumeAsync_RejectsFreshSession(t *testing.T) { + log := logger.NewNop() + mockRepo := newMockSessionRepoForPersistence() + svc := NewSessionPersistence(mockRepo, log) + + session := &scaci.Session{TenantID: 1, Resumed: false} + svc.PersistResumeAsync(testutil.TestContext(), session, "", "") + + assert.False(t, mockRepo.wasCreateCalled(), "a misused resume path must never create a session row") + assert.False(t, mockRepo.wasUpdateCalled(), "a fresh session must not be updated as a resume") +} + +// TestPersistConnectSync_NegotiatedVersionDefault validates that // negotiated_version defaults to ProtocolVersionString if empty. -// Ref: session_persistence.go:104-108 -func TestPersistConnectAsync_NegotiatedVersionDefault(t *testing.T) { +func TestPersistConnectSync_NegotiatedVersionDefault(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -431,8 +446,11 @@ func TestPersistConnectAsync_NegotiatedVersionDefault(t *testing.T) { } // Pass empty negotiatedVersion - svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", "") - mockRepo.waitForCompletion() + done := make(chan struct{}) + go func() { mockRepo.waitForCompletion(); close(done) }() + _, err := svc.PersistConnectSync(testutil.TestContext(), session, "", "", "", "", "", "") + <-done + require.NoError(t, err) require.True(t, mockRepo.wasCreateCalled()) createReq := mockRepo.getLastCreateReq() @@ -450,7 +468,7 @@ func TestPersistConnectAsync_NegotiatedVersionDefault(t *testing.T) { type slowSessionRepo struct { mockSessionRepoForPersistence delay time.Duration - createCalled bool + updateCalled bool mu sync.Mutex completionCh chan struct{} // Completion signal for deterministic waiting } @@ -462,19 +480,19 @@ func newSlowSessionRepo(delay time.Duration) *slowSessionRepo { } } -func (s *slowSessionRepo) CreateSession(_ context.Context, req *models.SCACISessionCreateRequest) (*models.SCACISession, error) { +func (s *slowSessionRepo) UpdateSession(_ context.Context, _, _ int64, _ *models.SCACISessionUpdateRequest) error { time.Sleep(s.delay) // Intentional delay for non-blocking test s.mu.Lock() - s.createCalled = true + s.updateCalled = true s.mu.Unlock() s.completionCh <- struct{}{} // Signal completion - return &models.SCACISession{ID: 1, TenantID: req.TenantID}, nil + return nil } -func (s *slowSessionRepo) wasCreateCalled() bool { +func (s *slowSessionRepo) wasUpdateCalled() bool { s.mu.Lock() defer s.mu.Unlock() - return s.createCalled + return s.updateCalled } func (s *slowSessionRepo) waitForCompletion() { @@ -485,10 +503,9 @@ func (s *slowSessionRepo) waitForCompletion() { // Metadata Persistence Tests (SCACI §3.3.1 info field) // ============================================================================ -// TestPersistConnectAsync_FreshSession_WritesNestedMetadata validates that -// nested metadata (including info field per §3.3.1) is persisted correctly. -// Ref: session_persistence.go:123 (Metadata in CreateRequest) -func TestPersistConnectAsync_FreshSession_WritesNestedMetadata(t *testing.T) { +// TestPersistConnectSync_WritesNestedMetadata validates that nested metadata +// (including info field per §3.3.1) is persisted correctly. +func TestPersistConnectSync_WritesNestedMetadata(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -513,8 +530,11 @@ func TestPersistConnectAsync_FreshSession_WritesNestedMetadata(t *testing.T) { }, } - svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) - mockRepo.waitForCompletion() + done := make(chan struct{}) + go func() { mockRepo.waitForCompletion(); close(done) }() + _, err := svc.PersistConnectSync(testutil.TestContext(), session, "", "", "", "", "", scaci.ProtocolVersionString) + <-done + require.NoError(t, err) require.True(t, mockRepo.wasCreateCalled(), "CreateSession should be called for fresh session") createReq := mockRepo.getLastCreateReq() @@ -538,10 +558,9 @@ func TestPersistConnectAsync_FreshSession_WritesNestedMetadata(t *testing.T) { assert.Len(t, caps, 2) } -// TestPersistConnectAsync_ResumedSession_WritesNestedMetadata validates that -// metadata is persisted on resume via UpdateSession. -// Ref: session_persistence.go:64 (Metadata in UpdateRequest) -func TestPersistConnectAsync_ResumedSession_WritesNestedMetadata(t *testing.T) { +// TestPersistResumeAsync_WritesNestedMetadata validates that metadata is +// persisted on resume via UpdateSession. +func TestPersistResumeAsync_WritesNestedMetadata(t *testing.T) { log := logger.NewNop() mockRepo := newMockSessionRepoForPersistence() svc := NewSessionPersistence(mockRepo, log) @@ -565,7 +584,7 @@ func TestPersistConnectAsync_ResumedSession_WritesNestedMetadata(t *testing.T) { }, } - svc.PersistConnectAsync(testutil.TestContext(), session, "", "", "", "TLS 1.3", "TLS_AES_256_GCM_SHA384", scaci.ProtocolVersionString) + svc.PersistResumeAsync(testutil.TestContext(), session, "TLS 1.3", "TLS_AES_256_GCM_SHA384") mockRepo.waitForCompletion() require.True(t, mockRepo.wasUpdateCalled(), "UpdateSession should be called for resumed session") diff --git a/KC-Core/pkg/scaci/contracts.go b/KC-Core/pkg/scaci/contracts.go index af8fae0..314a48a 100644 --- a/KC-Core/pkg/scaci/contracts.go +++ b/KC-Core/pkg/scaci/contracts.go @@ -699,39 +699,32 @@ type ErrorRecorder interface { // // Example Usage (handler_connect.go): // -// s.sessionPersistence.PersistConnectAsync(ctx, session, certFingerprint, certSubject, remoteAddr) +// s.sessionPersistence.PersistResumeAsync(ctx, session, tlsVersion, cipherSuite) // // Handler continues without waiting for DB write type SessionPersistence interface { - // PersistConnectAsync handles async session creation/update after Connect handshake - // - // This method replicates the current async persistence pattern from handler_connect.go:213-278 - // but encapsulates it in a service to follow SRP (handlers orchestrate, services execute). - // - // Session Lifecycle: - // - Resumed sessions: Updates lastHeartbeat, status, opId counters - // - New sessions: Creates record with snAcUuid, snScUuid, certificate metadata, negotiated version + // PersistResumeAsync updates the persisted row of a resumed session after + // the Connect handshake: lastHeartbeat, status, TLS evidence, opId + // counters, metadata. Fresh sessions are persisted synchronously via + // PersistConnectSync - this path never creates rows and never mutates the + // live session (the goroutine reads an immutable snapshot only). // // Parameters: - // - session: Session object with all metadata (Resumed flag determines create vs update) - // - certFingerprint: SHA256 fingerprint of client certificate - // - certSubject: Certificate subject DN - // - remoteAddr: Client IP address from connection + // - session: Resumed session (Resumed == true, ID > 0) // - tlsVersion: TLS version negotiated (e.g., "TLS 1.2", "TLS 1.3") per SCACI §1 // - cipherSuite: TLS cipher suite name per SCACI §1 - // - negotiatedVersion: Protocol version from successful negotiation per SCACI §§2.1-2.3 // // Goroutine Behavior: - // - Spawns goroutine with 5s timeout (matches current ConnectPersistTimeout) - // - Logs errors but doesn't propagate to handler - // - Updates session.ID field with database-assigned ID on create - PersistConnectAsync(ctx context.Context, session *Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) + // - Spawns goroutine bounded by ConnectPersistTimeout, detached from the + // caller's cancellation + // - Logs errors but doesn't propagate to handler (best-effort persistence) + PersistResumeAsync(ctx context.Context, session *Session, tlsVersion, cipherSuite string) // PersistConnectSync creates session synchronously, returning DB ID for operation logging. // // Used for fresh connects only - ensures session.ID is assigned BEFORE operation logging // so that Connect audit rows have real session IDs (SCACI §3.3-04 audit trail). // - // Resumed sessions continue using PersistConnectAsync (they already have session.ID > 0). + // Resumed sessions use PersistResumeAsync (they already have session.ID > 0). // // Parameters: // - ctx: Request context with timeout (typically ConnectPersistTimeout) diff --git a/KC-Core/pkg/scaci/handler_connect.go b/KC-Core/pkg/scaci/handler_connect.go index 8af2785..3ca6b3e 100644 --- a/KC-Core/pkg/scaci/handler_connect.go +++ b/KC-Core/pkg/scaci/handler_connect.go @@ -255,20 +255,9 @@ func (s *Server) handleConnectComplete(conn net.Conn, session *Session, opId int return conn.Close() } - // Capture TLS state before goroutine + // Capture TLS version and cipher suite per SCACI §1 evidence requirements tlsConn := conn.(*tls.Conn) state := tlsConn.ConnectionState() - - var certFingerprint, certSubject string - if len(state.PeerCertificates) > 0 { - cert := state.PeerCertificates[0] - hash := sha256.Sum256(cert.Raw) - certFingerprint = hex.EncodeToString(hash[:]) - certSubject = cert.Subject.String() - } - remoteAddr := conn.RemoteAddr().String() - - // Capture TLS version and cipher suite per SCACI §1 evidence requirements tlsVersion := TLSVersionName(state.Version) cipherSuite := tls.CipherSuiteName(state.CipherSuite) @@ -302,11 +291,10 @@ func (s *Server) handleConnectComplete(conn net.Conn, session *Session, opId int } } - // Persist async - delegate to SessionPersistence service - // Use session.NegotiatedVersion set during handleConnect (SCACI §§2.1-2.3) - // Skip if session was sync-persisted in handleConnect (SyncPersisted flag set for fresh sessions) + // Persist the resume update async - fresh sessions were sync-persisted in + // handleConnect (SyncPersisted flag), so only resumed sessions reach this. if !session.SyncPersisted { - s.sessionPersistence.PersistConnectAsync(s.sessionContext(session), session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, session.NegotiatedVersion) + s.sessionPersistence.PersistResumeAsync(s.sessionContext(session), session, tlsVersion, cipherSuite) } // SCACI §1: Replay pending operations on successful session resume diff --git a/KC-Core/pkg/scaci/mocks_test.go b/KC-Core/pkg/scaci/mocks_test.go index a389935..316b69f 100644 --- a/KC-Core/pkg/scaci/mocks_test.go +++ b/KC-Core/pkg/scaci/mocks_test.go @@ -216,16 +216,16 @@ func (m *MockOperationRecorder) Record( // MockSessionPersistence implements SessionPersistence interface for testing // // Provides mocks for: -// - PersistConnectAsync: Session creation/update after Connect +// - PersistResumeAsync: Resumed-session update after Connect // - PersistConnectSync: Synchronous session creation for audit trail // - PersistHeartbeatAsync: SCACI §3.4 ping heartbeat persistence type MockSessionPersistence struct { mock.Mock } -// PersistConnectAsync mocks SessionPersistence.PersistConnectAsync -func (m *MockSessionPersistence) PersistConnectAsync(ctx context.Context, session *Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) { - m.Called(ctx, session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion) +// PersistResumeAsync mocks SessionPersistence.PersistResumeAsync +func (m *MockSessionPersistence) PersistResumeAsync(ctx context.Context, session *Session, tlsVersion, cipherSuite string) { + m.Called(ctx, session, tlsVersion, cipherSuite) } // PersistConnectSync mocks SessionPersistence.PersistConnectSync From 712d2a1f7881f8a208d5e39bb3b539614d035707 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 19:30:10 +0200 Subject: [PATCH 14/25] ci: run the full KC-Core suite and the KC-DB postgres integration tests The test job excluded pkg/bssci although its -short run finishes in seconds, so the packages this branch changes most were never tested in CI. The KC-DB postgres testcontainers suite (~15 min) gets its own job in parallel with the image builds instead of a blanket exclusion. --- .github/workflows/pipeline-build.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pipeline-build.yml b/.github/workflows/pipeline-build.yml index b192929..e262758 100644 --- a/.github/workflows/pipeline-build.yml +++ b/.github/workflows/pipeline-build.yml @@ -73,10 +73,10 @@ jobs: go-version-file: go.work - name: Run Go tests (unit only, no DB/infra) run: | - echo "::group::Testing KC-Core (skip bssci integration)" - (cd KC-Core && go test -short -timeout 3m $(go list ./... | grep -v /pkg/bssci$)) + echo "::group::Testing KC-Core" + (cd KC-Core && go test -short -timeout 5m ./...) echo "::endgroup::" - echo "::group::Testing KC-DB (skip postgres integration)" + echo "::group::Testing KC-DB (postgres suite runs in test-postgres)" (cd KC-DB && go test -short -timeout 3m $(go list ./... | grep -v /postgres)) echo "::endgroup::" for mod in KC-Gateway KC-Identity KC-MQTT pkg; do @@ -85,6 +85,20 @@ jobs: echo "::endgroup::" done + # The KC-DB postgres suite runs against real testcontainers-managed + # PostgreSQL instances; it takes ~15 minutes and runs in parallel with the + # image builds rather than gating them. + test-postgres: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.work + - name: Run KC-DB postgres integration tests + run: | + (cd KC-DB && go test -timeout 25m ./storage/postgres/...) + build-kc-core: needs: [generate-version, lint, test] runs-on: ubuntu-latest From a578d7e85d40b95c7565444c41426ee3e70a757a Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 19:30:20 +0200 Subject: [PATCH 15/25] docs(bssci): annotate sentinel pass-through returns in attach persistence The attach transaction intentionally returns repository errors unwrapped so handlers can match sentinels via errors.Is; the pass-through annotations document that at each return site. --- .../services/bssci/endpoint_attachment_service.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/KC-Core/internal/services/bssci/endpoint_attachment_service.go b/KC-Core/internal/services/bssci/endpoint_attachment_service.go index f684a88..ab7dae4 100644 --- a/KC-Core/internal/services/bssci/endpoint_attachment_service.go +++ b/KC-Core/internal/services/bssci/endpoint_attachment_service.go @@ -433,7 +433,7 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if err := tx.EndPoints().UpdateFields(ctx, rec.TenantID, rec.EndpointID, rec.EndpointUpdates); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateEndpointAttachMetadata, "error", err) - return err + return err // Preserves sentinel: handlers match repository errors via errors.Is } endpointIDStr := fmt.Sprintf("%d", rec.EndpointID) @@ -441,7 +441,7 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if getErr != nil { commitErr = getErr p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToLoadEndpointSession, "error", getErr) - return getErr + return getErr // Preserves sentinel: handlers match repository errors via errors.Is } now := time.Now().UTC() @@ -468,7 +468,7 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if err := tx.EndPointSessions().Update(ctx, activeSession); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateEndpointSession, "error", err) - return err + return err // Preserves sentinel: handlers match repository errors via errors.Is } } else { newSession := &models.EndPointSession{ @@ -487,14 +487,14 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if err := tx.EndPointSessions().Create(ctx, newSession); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToCreateEndpointSession, "error", err) - return err + return err // Preserves sentinel: handlers match repository errors via errors.Is } } if err := tx.Commit(); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToCommitAttachTransaction, "error", err) - return err + return err // Preserves sentinel: handlers match repository errors via errors.Is } commitErr = nil return nil From 4a4bc9fdc03372ff54ac2842dfd990d3568531d4 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 19:30:40 +0200 Subject: [PATCH 16/25] chore(release): stamp manifest at 7b00145 for the Codex-review remediation --- pkg/version/release-manifest.json | 14 +++++++------- release/manifest.json | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/version/release-manifest.json b/pkg/version/release-manifest.json index 66c56f5..f4c3ab1 100644 --- a/pkg/version/release-manifest.json +++ b/pkg/version/release-manifest.json @@ -1,19 +1,19 @@ { - "version": "v1.0.0+53325eff-dirty", - "buildTime": "2026-05-11T17:10:34Z", - "gitCommit": "53325effa589017ab62c486a8d952c3366d08cd6", - "gitBranch": "main", + "version": "v1.2.0+7b00145", + "buildTime": "2026-07-22T17:30:40Z", + "gitCommit": "7b0014505900035518a45dbb86082273b3925991", + "gitBranch": "CHIRP-3538-bssci-version-negotiation-eui64", "buildUser": "timkrav", - "goVersion": "go1.25.5", + "goVersion": "go1.26.4", "artifacts": { "kcCore": "KC-Core/bin/kilocenter" }, - "schemaVersion": 133, + "schemaVersion": 142, "edition": "Community Edition", "editionCode": "ce", "licenseId": "AGPL-3.0-or-later", "licenseUrl": "https://www.gnu.org/licenses/agpl-3.0.html", - "sourceUrl": "https://github.com/chirpwireless/KiloServiceCenter", + "sourceUrl": "https://github.com/Kiloiot/kilo-service-center", "docsUrl": "https://docs.kiloiot.io/", "homepageUrl": "https://kiloiot.io/mioty-service-center/", "trademarkNotice": "KiloCenter is a trademark of Tim Kravchunovsky." diff --git a/release/manifest.json b/release/manifest.json index 66c56f5..f4c3ab1 100644 --- a/release/manifest.json +++ b/release/manifest.json @@ -1,19 +1,19 @@ { - "version": "v1.0.0+53325eff-dirty", - "buildTime": "2026-05-11T17:10:34Z", - "gitCommit": "53325effa589017ab62c486a8d952c3366d08cd6", - "gitBranch": "main", + "version": "v1.2.0+7b00145", + "buildTime": "2026-07-22T17:30:40Z", + "gitCommit": "7b0014505900035518a45dbb86082273b3925991", + "gitBranch": "CHIRP-3538-bssci-version-negotiation-eui64", "buildUser": "timkrav", - "goVersion": "go1.25.5", + "goVersion": "go1.26.4", "artifacts": { "kcCore": "KC-Core/bin/kilocenter" }, - "schemaVersion": 133, + "schemaVersion": 142, "edition": "Community Edition", "editionCode": "ce", "licenseId": "AGPL-3.0-or-later", "licenseUrl": "https://www.gnu.org/licenses/agpl-3.0.html", - "sourceUrl": "https://github.com/chirpwireless/KiloServiceCenter", + "sourceUrl": "https://github.com/Kiloiot/kilo-service-center", "docsUrl": "https://docs.kiloiot.io/", "homepageUrl": "https://kiloiot.io/mioty-service-center/", "trademarkNotice": "KiloCenter is a trademark of Tim Kravchunovsky." From c663a0c5cda8808ee025dffef2debbef41000203 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 20:51:29 +0200 Subject: [PATCH 17/25] fix(bssci): reject resume before conRsp when persisted work cannot be rebuilt Semantic reconstruction moved from the post-conCmp reissue loop into the pre-conRsp resume preparation, alongside the existing strict decode: a persisted operation that cannot be rebuilt rejects the whole resume with EAGAIN, preserving every row and its downlink queue state, instead of deleting recoverable work after activation (which could orphan a reserved queue row - there is no reclaim sweeper). A reissue send failure now aborts connect completion: the handler returns the error, an ambiguous write closes the transport, and status polling never starts on a dead connection. Covered by per-type malformed-row rejection tests, an aborted-reissue test, and a production-statusService eviction test. --- .../bssci/status_service_integration_test.go | 46 ++++- KC-Core/pkg/bssci/log_messages.go | 9 +- KC-Core/pkg/bssci/resume_reissue_test.go | 159 ++++++++++++++---- KC-Core/pkg/bssci/server.go | 148 +++++++++------- KC-Core/pkg/bssci/test_helpers_test.go | 7 +- 5 files changed, 268 insertions(+), 101 deletions(-) diff --git a/KC-Core/internal/services/bssci/status_service_integration_test.go b/KC-Core/internal/services/bssci/status_service_integration_test.go index 8e7f750..2867c61 100644 --- a/KC-Core/internal/services/bssci/status_service_integration_test.go +++ b/KC-Core/internal/services/bssci/status_service_integration_test.go @@ -17,8 +17,9 @@ import ( // mockPendingOperationRepositoryWithCallTracking extends mockPendingOperationRepository with call count tracking type mockPendingOperationRepositoryWithCallTracking struct { - createCallCount int - mu sync.Mutex + createCallCount int + deleteBySessionCallCount int + mu sync.Mutex } func (m *mockPendingOperationRepositoryWithCallTracking) Create(_ context.Context, _ *interfaces.PendingOperationRequest) error { @@ -48,6 +49,9 @@ func (m *mockPendingOperationRepositoryWithCallTracking) DeleteByOperation(_ con } func (m *mockPendingOperationRepositoryWithCallTracking) DeleteBySession(_ context.Context, _ int64) (int64, error) { + m.mu.Lock() + m.deleteBySessionCallCount++ + m.mu.Unlock() return 0, nil } @@ -319,3 +323,41 @@ func TestStatusServiceSingleWriterPattern(t *testing.T) { t.Logf("PASS: Single writer pattern enforced") } + +// TestStatusServiceEvictCachedOperationsCacheOnly verifies the teardown sweep +// on the production statusService: only the dead session's cached entries are +// removed, other sessions' entries survive, and the repository (the durable +// rows a later resume hydrates from) is never touched. +func TestStatusServiceEvictCachedOperationsCacheOnly(t *testing.T) { + pendingOps := make(map[bssci.SessionOpKey]*bssci.PendingOperation) + var mu sync.RWMutex + + log := logger.NewNop() + mockRepo := &mockPendingOperationRepositoryWithCallTracking{} + statusSvc := NewStatusService(&pendingOps, &mu, mockRepo, log) + + dead := &bssci.Session{ProtocolSessionState: bssci.ProtocolSessionState{ID: "dead-session", DbSessionID: 2001}} + alive := &bssci.Session{ProtocolSessionState: bssci.ProtocolSessionState{ID: "alive-session", DbSessionID: 2002}} + + ctx := testutil.TestContext() + for opID := int64(-1); opID >= -3; opID-- { + require.NoError(t, statusSvc.RecordPendingOperation(ctx, dead, opID, + &bssci.PendingOperation{SessionSlug: dead.ID, OperationID: opID, OperationType: "attPrp"}, dead.DbSessionID)) + } + require.NoError(t, statusSvc.RecordPendingOperation(ctx, alive, -1, + &bssci.PendingOperation{SessionSlug: alive.ID, OperationID: -1, OperationType: "attPrp"}, alive.DbSessionID)) + + statusSvc.EvictCachedOperations(dead) + + mu.RLock() + remaining := len(pendingOps) + _, aliveKept := pendingOps[bssci.SessionOpKey{SessionID: alive.ID, OperationID: -1}] + mu.RUnlock() + assert.Equal(t, 1, remaining, "only the surviving session's entry may remain") + assert.True(t, aliveKept, "other sessions' cached operations must survive the sweep") + + mockRepo.mu.Lock() + deletes := mockRepo.deleteBySessionCallCount + mockRepo.mu.Unlock() + assert.Zero(t, deletes, "eviction is cache-only; persisted rows stay for resume") +} diff --git a/KC-Core/pkg/bssci/log_messages.go b/KC-Core/pkg/bssci/log_messages.go index a897933..30bd18b 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -717,12 +717,9 @@ const ( LogBSSCIFailedToUnmarshalOperationData = "Failed to unmarshal operation data" // LogBSSCIFailedToUnmarshalMetadata is a log message constant LogBSSCIFailedToUnmarshalMetadata = "Failed to unmarshal metadata" - // LogBSSCIFailedToReconstituteDLDataQueSkipping is a log message constant - LogBSSCIFailedToReconstituteDLDataQueSkipping = "Failed to reconstitute dlDataQue, skipping" - // LogBSSCIFailedToReconstituteDLDataRevSkipping is a log message constant - LogBSSCIFailedToReconstituteDLDataRevSkipping = "Failed to reconstitute dlDataRev, skipping" - // LogBSSCIFailedToReconstituteULDataTxSkipping is a log message constant - LogBSSCIFailedToReconstituteULDataTxSkipping = "Failed to reconstitute ulDataTx, skipping" + // LogBSSCIFailedToReconstitutePendingOperation indicates a persisted + // operation could not be semantically rebuilt; the resume is rejected. + LogBSSCIFailedToReconstitutePendingOperation = "Failed to reconstitute pending operation, rejecting resume" // LogBSSCIFailedToReissuePendingOperation is a log message constant LogBSSCIFailedToReissuePendingOperation = "Failed to reissue pending operation" // LogBSSCIFailedToRemovePendingOperation is a log message constant diff --git a/KC-Core/pkg/bssci/resume_reissue_test.go b/KC-Core/pkg/bssci/resume_reissue_test.go index fb00a43..6771b4a 100644 --- a/KC-Core/pkg/bssci/resume_reissue_test.go +++ b/KC-Core/pkg/bssci/resume_reissue_test.go @@ -3,6 +3,8 @@ package bssci import ( "bytes" "context" + "encoding/binary" + "fmt" "net" "testing" "time" @@ -22,6 +24,8 @@ import ( type countingConn struct { frames int failWrite bool + closes int + buf bytes.Buffer } func (c *countingConn) Read(_ []byte) (int, error) { return 0, net.ErrClosed } @@ -32,9 +36,33 @@ func (c *countingConn) Write(b []byte) (int, error) { if c.failWrite { return 0, net.ErrClosed } + c.buf.Write(b) return len(b), nil } -func (c *countingConn) Close() error { return nil } + +// writtenCommands decodes the buffered outbound frames and returns their +// command names in order. +func (c *countingConn) writtenCommands(t *testing.T) []string { + t.Helper() + var commands []string + raw := c.buf.Bytes() + for len(raw) >= HeaderSize { + require.True(t, bytes.Equal(raw[:8], mioty.MIOTYFrameIdentifier[:]), "frame identifier") + payloadLen := int(binary.LittleEndian.Uint32(raw[8:HeaderSize])) + require.LessOrEqual(t, HeaderSize+payloadLen, len(raw), "complete frame") + decoded, err := decodeMessage(raw[HeaderSize:HeaderSize+payloadLen], EncodingJSON) + require.NoError(t, err) + cmd, _ := decoded["command"].(string) + commands = append(commands, cmd) + raw = raw[HeaderSize+payloadLen:] + } + return commands +} + +func (c *countingConn) Close() error { + c.closes++ + return nil +} func (c *countingConn) LocalAddr() net.Addr { return &net.TCPAddr{} } func (c *countingConn) RemoteAddr() net.Addr { return &net.TCPAddr{} } func (c *countingConn) SetDeadline(_ time.Time) error { return nil } @@ -109,9 +137,10 @@ func statusReissueOp(opID int64) *PendingOperation { } } -// TestResumeReissueAbortsOnSendFailure: once a reissue send fails the -// connection is broken, so the loop stops instead of attempting the remaining -// operations; their rows stay persisted for the next resume. +// TestResumeReissueAbortsOnSendFailure: a reissue send failure aborts +// activation - handleConnectComplete errors, the ambiguous-write transport is +// closed, and status polling never starts. The rows stay persisted for the +// next resume. func TestResumeReissueAbortsOnSendFailure(t *testing.T) { server := newResumeReissueServer(t) conn := &countingConn{failWrite: true} @@ -122,41 +151,109 @@ func TestResumeReissueAbortsOnSendFailure(t *testing.T) { t.Cleanup(func() { stopSessionStatus(session) }) msg := &Message{OpId: 0, Command: mioty.CmdConnectComplete} - require.NoError(t, server.handleConnectComplete(server, session, msg, nil)) + err := server.handleConnectComplete(server, session, msg, nil) + require.Error(t, err, "a failed reissue must abort connect completion") + require.ErrorIs(t, err, ErrAmbiguousWrite) assert.Equal(t, 1, conn.frames, "the first failed send must abort the reissue loop before the second operation") + assert.Positive(t, conn.closes, + "an ambiguous write must close the transport") + + session.mu.Lock() + stopStatus := session.stopStatus + session.mu.Unlock() + assert.Nil(t, stopStatus, "status polling must not start after an aborted reissue") } -// TestResumeRemovesIrrecoverableOperation: an operation whose payload cannot -// be reconstituted is deleted durably so it does not resurface on every -// future resume, and the loop continues with the remaining operations. -func TestResumeRemovesIrrecoverableOperation(t *testing.T) { - server := newResumeReissueServer(t) - statusSvc := server.statusSvc.(*memoryStatusService) - conn := &countingConn{} - corrupt := &PendingOperation{ - OperationID: -3, - OperationType: mioty.CmdULDataTransmit, - Message: map[string]interface{}{"command": mioty.CmdULDataTransmit, "opId": int64(-3)}, - Metadata: map[string]interface{}{"bogus": true}, +// malformedResumeRow persists as a valid strict-decodable row whose metadata +// cannot be semantically reconstructed: ulDataTx and dlDataRev fail on the +// missing key/typed fields, dlDataQue on an undecodable payload. +func malformedResumeRow(opID int64, opType string) PersistedOperation { + metadata := `{"bogus":true}` + if opType == mioty.CmdDLDataQueue { + metadata = `{"payloads":["%%%not-base64%%%"]}` } - session := newResumeSession(conn, []*PendingOperation{ - corrupt, - statusReissueOp(-4), - }) - t.Cleanup(func() { stopSessionStatus(session) }) + return PersistedOperation{ + OperationID: opID, + OperationType: opType, + OperationData: []byte(fmt.Sprintf(`{"command":%q,"opId":%d}`, opType, opID)), + Metadata: []byte(metadata), + } +} - msg := &Message{OpId: 0, Command: mioty.CmdConnectComplete} - require.NoError(t, server.handleConnectComplete(server, session, msg, nil)) +// TestResumeRejectedWhenReconstructionFails: a persisted operation that +// cannot be semantically rebuilt rejects the whole resume with EAGAIN before +// conRsp - no row is deleted, no queue state changes, and no session +// activates. Covers all three payload-bearing operation types. +func TestResumeRejectedWhenReconstructionFails(t *testing.T) { + for _, opType := range []string{mioty.CmdULDataTransmit, mioty.CmdDLDataQueue, mioty.CmdDLDataRevoke} { + t.Run(opType, func(t *testing.T) { + server := newResumeReissueServer(t) + statusSvc := server.statusSvc.(*memoryStatusService) + sessionSvc := server.sessionSvc.(*mockSessionService) - statusSvc.mu.RLock() - removed := append([]int64(nil), statusSvc.removedOps...) - statusSvc.mu.RUnlock() - assert.Equal(t, []int64{-3}, removed, - "the irrecoverable operation's persisted row must be removed") - assert.Equal(t, 1, conn.frames, - "the valid operation after the corrupt one must still be reissued") + // A resumable prior session identified by its snBsUuid + prevUUID := make([]byte, 16) + for i := range prevUUID { + prevUUID[i] = byte(i + 1) + } + prev := &Session{ProtocolSessionState: ProtocolSessionState{ + ID: "previous-runtime-session", + BaseStationEUI: TestBsEui01, + SessionUUID: prevUUID, + DbSessionID: 7, + LastScOpId: -1, + }} + sessionSvc.StoreSessionByUUID(prev) + + statusSvc.mu.Lock() + statusSvc.persistedRows = []PersistedOperation{malformedResumeRow(-2, opType)} + statusSvc.mu.Unlock() + + conn := &countingConn{} + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: "resume-reject-session", + BaseStationEUI: TestBsEui01, + Encoding: EncodingJSON, + }, + Conn: conn, + } + + snBsUUID := make([]interface{}, 16) + for i := range snBsUUID { + snBsUUID[i] = int64(i + 1) + } + connectData := map[string]interface{}{ + "command": mioty.CmdConnect, + "opId": int64(0), + "version": mioty.MIOTYProtocolVersion, + "bsEui": int64(TestBsEui01), + "bidi": true, + "snBsUuid": snBsUUID, + } + msg := &Message{OpId: 0, Command: mioty.CmdConnect, Data: connectData} + require.NoError(t, server.handleConnect(server, session, msg, connectData), + "a rejected connect awaits errorAck instead of failing the handler") + + commands := conn.writtenCommands(t) + require.Equal(t, []string{mioty.CmdError}, commands, + "the resume must be rejected with an error frame and never reach conRsp") + + statusSvc.mu.RLock() + rows := len(statusSvc.persistedRows) + removed := len(statusSvc.removedOps) + statusSvc.mu.RUnlock() + assert.Equal(t, 1, rows, "the malformed row must be preserved for operator inspection") + assert.Zero(t, removed, "no persisted row may be deleted on a rejected resume") + + server.mu.Lock() + live := len(server.sessions) + server.mu.Unlock() + assert.Zero(t, live, "no session may activate on a rejected resume") + }) + } } // TestTeardownEvictsCacheKeepsRows: when an active session's connection is diff --git a/KC-Core/pkg/bssci/server.go b/KC-Core/pkg/bssci/server.go index f90c413..0b5ae2d 100644 --- a/KC-Core/pkg/bssci/server.go +++ b/KC-Core/pkg/bssci/server.go @@ -158,10 +158,14 @@ type Server struct { // dependencies; Start refuses to run without them. runtimeConfigured bool - // started records that Start ran; late runtime reconfiguration of a - // serving instance is rejected. + // started records that Start committed; late runtime reconfiguration and + // double starts are rejected. started bool + // stopped records that Stop ran; a stopped server cannot be restarted + // and repeated Stop calls are no-ops. + stopped bool + // Relay outbox writer for CE mode: enqueues unknown-endpoint uplinks for federation relay relayOutbox RelayOutboxWriter @@ -1068,25 +1072,39 @@ func certsExist(certFile, keyFile, caFile string) bool { // Start starts the BSSCI server. If TLS certificates are not yet available // (e.g., fresh deployment before certs are generated via UI), the listener // is deferred and a background goroutine polls until the certificates appear. +// A validation failure leaves the server in its pre-Start state so the +// composition root can complete the wiring and try again; a successful Start +// is committed exactly once and cannot follow Stop. func (s *Server) Start() error { // The composition root must supply the circular dependencies before the // server accepts traffic; an incompletely wired server refuses to start. s.mu.Lock() - if !s.runtimeConfigured { + switch { + case s.stopped: + s.mu.Unlock() + return fmt.Errorf("server already stopped: a stopped server cannot be restarted") + case s.started: + s.mu.Unlock() + return fmt.Errorf("server already started") + case !s.runtimeConfigured: s.mu.Unlock() return fmt.Errorf("server runtime not configured: call ConfigureRuntime before Start") } - s.started = true - s.mu.Unlock() if err := s.validateRuntimeWiring(); err != nil { + s.mu.Unlock() return fmt.Errorf("server wiring incomplete: %w", err) } - - // The dlRxStatQry expiry sweep runs independently of the TLS listener path. - s.startDLRXQueryExpiryWorker() + s.started = true + s.mu.Unlock() if certsExist(s.config.TLSCert, s.config.TLSKey, s.config.TLSCACert) { - return s.startTLSListener() + if err := s.startTLSListener(); err != nil { + return err + } + // Background work starts only once the listener is committed, so an + // immediate TLS failure leaves nothing running. + s.startDLRXQueryExpiryWorker() + return nil } s.logger.WarnContext(s.safeCtx(), LogBSSCICertsNotFound, @@ -1121,6 +1139,7 @@ func (s *Server) waitForCertsAndStart() { s.logger.ErrorContext(s.safeCtx(), LogBSSCIDeferredListenerFailed, "error", err) continue } + s.startDLRXQueryExpiryWorker() return } } @@ -1178,6 +1197,14 @@ func (s *Server) startTLSListener() error { // Stop stops the BSSCI server func (s *Server) Stop() error { + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return nil + } + s.stopped = true + s.mu.Unlock() + s.cancel() if s.listener != nil { if err := s.listener.Close(); err != nil { @@ -2207,6 +2234,17 @@ func (s *Server) handleConnect(_ *Server, session *Session, msg *Message, data m "sessionID", session.DbSessionID) return s.rejectConnect(session, msg.OpId, POSIX_EAGAIN, errSessionResumeUnavailable) } + + // Semantic reconstruction also happens BEFORE conRsp: a resumable + // operation that cannot be rebuilt rejects the resume in the same + // way, preserving every row and its queue state instead of + // silently dropping recoverable work after activation. + if recErr := s.reconstituteResumeOperations(ctx, pendingOps); recErr != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstitutePendingOperation, + "error", recErr, + "sessionID", session.DbSessionID) + return s.rejectConnect(session, msg.OpId, POSIX_EAGAIN, errSessionResumeUnavailable) + } session.resumePendingOps = pendingOps // Counter floor: the SC counter must be at or below every persisted @@ -2312,19 +2350,39 @@ func (s *Server) terminateInconsistentResume(ctx context.Context, previous *Sess return nil } -// removeIrrecoverablePendingOperation deletes the persisted row of a pending -// operation that failed reconstitution during resume. Without the delete the -// corrupt row would be re-read and re-skipped on every future resume. -func (s *Server) removeIrrecoverablePendingOperation(ctx context.Context, session *Session, opID int64) { - if s.statusSvc == nil { - return - } - if err := s.statusSvc.RemovePendingOperation(ctx, session, opID); err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToRemovePendingOperationFromDatabase, - "error", err, - "opId", opID, - "sessionID", session.DbSessionID) +// reconstituteResumeOperations semantically rebuilds every payload-bearing +// pending operation in place before the resume is offered. Any failure +// rejects the whole resume so no row or downlink queue state is ever +// mutated for work the service center can no longer represent on the wire. +func (s *Server) reconstituteResumeOperations(ctx context.Context, pendingOps []*PendingOperation) error { + for _, op := range pendingOps { + if op.Metadata == nil { + continue + } + var ( + reconstituted map[string]interface{} + err error + ) + switch op.OperationType { + case mioty.CmdULDataTransmit: + reconstituted, err = s.reconstitueULDataTxMessage(op.Message, op.Metadata, op) + case mioty.CmdDLDataQueue: + reconstituted, err = s.reconstitueDLDataQueMessage(op.Message, op.Metadata, op) + case mioty.CmdDLDataRevoke: + reconstituted, err = s.reconstitueDLDataRevMessage(op.Message, op.Metadata) + default: + continue + } + if err != nil { + s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstitutePendingOperation, + "error", err, + "opId", op.OperationID, + "type", op.OperationType) + return fmt.Errorf("reconstitute pending operation %d (%s): %w", op.OperationID, op.OperationType, err) + } + op.Message = reconstituted } + return nil } // handleConnectComplete handles the connect complete operation. The connect @@ -2454,46 +2512,10 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message "opId", op.OperationID, "type", op.OperationType) - // Reconstitute operations BEFORE storing or sending. An operation - // that cannot be reconstituted is irrecoverable: its persisted row - // is removed so it does not resurface on every future resume. - if op.OperationType == mioty.CmdULDataTransmit && op.Metadata != nil { - reconstituted, err := s.reconstitueULDataTxMessage(op.Message, op.Metadata, op) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteULDataTxSkipping, - "error", err, - "opId", op.OperationID) - s.removeIrrecoverablePendingOperation(ctx, session, op.OperationID) - continue - } - op.Message = reconstituted - } else if op.OperationType == mioty.CmdDLDataQueue && op.Metadata != nil { - // Reconstitute dlDataQue with counter-dependent payloads - reconstituted, err := s.reconstitueDLDataQueMessage(op.Message, op.Metadata, op) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataQueSkipping, - "error", err, - "opId", op.OperationID) - s.removeIrrecoverablePendingOperation(ctx, session, op.OperationID) - continue - } - op.Message = reconstituted - } else if op.OperationType == mioty.CmdDLDataRevoke && op.Metadata != nil { - // Reconstitute mioty.CmdDLDataRevoke with proper integer types - reconstituted, err := s.reconstitueDLDataRevMessage(op.Message, op.Metadata) - if err != nil { - s.logger.ErrorContext(ctx, LogBSSCIFailedToReconstituteDLDataRevSkipping, - "error", err, - "opId", op.OperationID) - s.removeIrrecoverablePendingOperation(ctx, session, op.OperationID) - continue - } - op.Message = reconstituted - } - // Cache-only hydration: the loaded DB rows are authoritative and // must not be UPSERTed back. BS-initiated (positive) operations // are hydrated for response correlation but never transmitted. + // Semantic reconstruction already happened before conRsp. if s.statusSvc != nil { s.statusSvc.RestorePendingOperation(session, op.OperationID, op) } @@ -2505,13 +2527,17 @@ func (s *Server) handleConnectComplete(_ *Server, session *Session, msg *Message // Normalize message types to fix JSON float64 conversion before reissuing normalizedMsg := s.normalizeMessageTypes(op.Message, op.OperationType) if err := s.sendMessage(session, normalizedMsg); err != nil { - // A send failure means the connection is broken; the remaining - // operations stay persisted and reissue on the next resume. + // A send failure means the connection is broken: abort + // activation so status polling never starts on a dead + // transport. The rows stay persisted for the next resume. s.logger.ErrorContext(ctx, LogBSSCIFailedToReissuePendingOperation, "error", err, "opId", op.OperationID, "type", op.OperationType) - break + if errors.Is(err, ErrAmbiguousWrite) { + s.closeTransportAfterWriteFailure(session, op.OperationID, err) + } + return fmt.Errorf("reissue pending operation %d after resume: %w", op.OperationID, err) } } } else if session.DbSessionID > 0 { diff --git a/KC-Core/pkg/bssci/test_helpers_test.go b/KC-Core/pkg/bssci/test_helpers_test.go index 681e921..b225c7e 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -545,6 +545,9 @@ type memoryStatusService struct { // deleteSessionCalls counts DeletePendingOperations calls so teardown // tests can assert persisted rows of an active session are preserved. deleteSessionCalls int + // persistedRows backs PersistedOperations for resume tests that inject + // raw rows (including malformed ones) into the load path. + persistedRows []PersistedOperation } // StatusMetadataUpdate captures one UpdatePendingOperationMetadata call. @@ -646,7 +649,9 @@ func (m *memoryStatusService) UpdatePendingOperationMetadata(_ context.Context, } func (m *memoryStatusService) PersistedOperations(_ context.Context, _ int64) ([]PersistedOperation, error) { - return nil, nil + m.mu.RLock() + defer m.mu.RUnlock() + return append([]PersistedOperation(nil), m.persistedRows...), nil } func (m *memoryStatusService) DeletePendingOperations(_ context.Context, session *Session) (int64, error) { From 68c6e79f42be8c247e56d2cbb593ac334f1c56d2 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 20:51:29 +0200 Subject: [PATCH 18/25] fix(gateway): stream results recorded only while the breaker is closed A stream admitted while the breaker was closed could finish during half-open, and RecordResult's Execute would consume a probe slot and close or reopen the breaker - recovery was not exclusively decided by the unary probes. RecordResult is now a no-op outside the closed state, with a deterministic old-stream-during-half-open test. --- .../cmd/gateway/breaker_interceptor_test.go | 61 +++++++++++++++++++ .../internal/resilience/circuit_breaker.go | 8 ++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/KC-Gateway/cmd/gateway/breaker_interceptor_test.go b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go index cb6c015..3b3ca19 100644 --- a/KC-Gateway/cmd/gateway/breaker_interceptor_test.go +++ b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go @@ -159,6 +159,67 @@ func TestBreakerStream_HalfOpenRejectsStreams(t *testing.T) { "streams must be admitted again once the probes closed the breaker") } +// TestBreakerStream_OldStreamCannotAlterHalfOpenRecovery: a stream admitted +// while the breaker was closed but finishing during half-open must not +// consume a probe slot or flip the breaker - recovery belongs exclusively to +// the slot-accounted unary probes. +func TestBreakerStream_OldStreamCannotAlterHalfOpenRecovery(t *testing.T) { + cfg := config.GatewayResilienceConfig{ + CBMaxRequests: 2, + CBInterval: time.Minute, + CBTimeout: 50 * time.Millisecond, + CBFailureThreshold: 1, + } + core := resilience.NewUpstreamBreaker("core", cfg) + identity := resilience.NewUpstreamBreaker("identity", cfg) + interceptor := breakerStreamInterceptor(core, identity, 0) + + // Old stream admitted while closed; its handler blocks until released. + // The handler signals admission so the breaker is guaranteed to trip + // only after the old stream is already running. + release := make(chan error) + admitted := make(chan struct{}) + oldStreamDone := make(chan error, 1) + go func() { + oldStreamDone <- interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), + func(_ interface{}, _ grpc.ServerStream) error { + close(admitted) + return <-release + }) + }() + <-admitted + + // Trip the breaker through another stream, then advance to half-open. + _ = interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo("/x/Stream"), + func(_ interface{}, _ grpc.ServerStream) error { + return status.Error(codes.Unavailable, "upstream gone") + }) + require.Equal(t, resilience.BreakerOpen, core.State()) + time.Sleep(60 * time.Millisecond) + require.Equal(t, resilience.BreakerHalfOpen, core.State()) + + // The old stream finishes - successfully and then (via a second run) + // with an error; neither may move the breaker out of half-open. + release <- nil + require.NoError(t, <-oldStreamDone) + assert.Equal(t, resilience.BreakerHalfOpen, core.State(), + "an old stream's success must not close a half-open breaker") + + core.RecordResult(status.Error(codes.Unavailable, "late stream failure")) + assert.Equal(t, resilience.BreakerHalfOpen, core.State(), + "an old stream's failure must not reopen a half-open breaker") + + // The unary probes alone complete recovery. + unary := "/kilocenter.api.v1.KiloCenterService/ListEndPoints" + require.True(t, unaryMethods[unary]) + ok := func(_ interface{}, _ grpc.ServerStream) error { return nil } + for i := 0; i < int(cfg.CBMaxRequests); i++ { + require.NoError(t, interceptor(nil, &fakeServerStream{ctx: testutil.TestContext()}, streamInfo(unary), ok)) + } + assert.Equal(t, resilience.BreakerClosed, core.State(), + "recovery is decided by the unary probes") +} + // TestBreakerStream_SuccessfulStreamRecordsSuccess: clean stream completion // resets the consecutive-failure count. func TestBreakerStream_SuccessfulStreamRecordsSuccess(t *testing.T) { diff --git a/KC-Gateway/internal/resilience/circuit_breaker.go b/KC-Gateway/internal/resilience/circuit_breaker.go index 48e96f7..38ebf9a 100644 --- a/KC-Gateway/internal/resilience/circuit_breaker.go +++ b/KC-Gateway/internal/resilience/circuit_breaker.go @@ -69,8 +69,14 @@ func (b *UpstreamBreaker) Execute(fn func() error) error { // RecordResult feeds a completed call's outcome into the breaker's counters // without holding an execution slot for the call's duration. Used for // long-lived streaming RPCs, whose terminal error is classified after the -// stream ends; while the breaker is open the record is a no-op rejection. +// stream ends. Recording happens only while the breaker is closed: a stream +// that was admitted earlier but finishes during half-open must not consume a +// probe slot or flip the breaker - recovery is decided exclusively by the +// slot-accounted unary probes, and an open breaker rejects anyway. func (b *UpstreamBreaker) RecordResult(err error) { + if b.cb.State() != gobreaker.StateClosed { + return + } _, _ = b.cb.Execute(func() (any, error) { return nil, err }) From b7de05754e0496b09c9d1ea299db698a7aec3efe Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 20:51:29 +0200 Subject: [PATCH 19/25] fix(bssci): commit lifecycle state only after successful validation Start now validates the wiring before setting started, so a failed validation leaves the server startable once the composition root completes it; a second Start and any Start after Stop are rejected, and Stop is idempotent. The dlRxStatQry expiry worker starts only after the listener is actually up (immediate or deferred-certificate path). Adds real Start/Stop lifecycle tests and covers the four trailing SCACI constructor checks (orgResolver, sessionSnapshotProvider, propagationSvc, errorRecorder). --- KC-Core/pkg/bssci/runtime_wiring_test.go | 36 +++++++++++++- KC-Core/pkg/scaci/server_test.go | 61 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/KC-Core/pkg/bssci/runtime_wiring_test.go b/KC-Core/pkg/bssci/runtime_wiring_test.go index 21395cd..eea0810 100644 --- a/KC-Core/pkg/bssci/runtime_wiring_test.go +++ b/KC-Core/pkg/bssci/runtime_wiring_test.go @@ -50,7 +50,9 @@ func TestConfigureRuntimeRejectsDoubleCall(t *testing.T) { } // TestConfigureRuntimeRejectedAfterStart: a serving instance cannot be -// reconfigured. +// reconfigured. The started flag is set directly: a real committed Start +// requires the full production dependency set (~19 stub collaborators for a +// test), while this guard reads exactly the field Start commits. func TestConfigureRuntimeRejectedAfterStart(t *testing.T) { server := newResumeReissueServer(t) server.mu.Lock() @@ -62,6 +64,38 @@ func TestConfigureRuntimeRejectedAfterStart(t *testing.T) { assert.Contains(t, err.Error(), "after Start") } +// TestStartLifecycle drives the real Start/Stop entry points on a partially +// wired server: a wiring failure leaves the server startable (started never +// commits), Stop is idempotent, and a stopped server cannot be restarted. +func TestStartLifecycle(t *testing.T) { + server := newResumeReissueServer(t) + + err := server.Start() + require.Error(t, err, "Start before ConfigureRuntime must fail") + assert.Contains(t, err.Error(), "ConfigureRuntime") + + require.NoError(t, server.ConfigureRuntime(newRuntimeDeps())) + + err = server.Start() + require.Error(t, err, "the test harness is deliberately under-wired") + assert.Contains(t, err.Error(), "wiring incomplete") + + // A failed validation must not commit lifecycle state: the same error + // repeats instead of "already started". + err = server.Start() + require.Error(t, err) + assert.Contains(t, err.Error(), "wiring incomplete", + "a failed Start must leave the server startable") + + require.NoError(t, server.Stop()) + require.NoError(t, server.Stop(), "Stop must be idempotent") + + err = server.Start() + require.Error(t, err) + assert.Contains(t, err.Error(), "stopped", + "a stopped server must not restart") +} + // TestValidateRuntimeWiringReportsMissingDependency: an incompletely wired // server is rejected at startup instead of failing as a nil dereference under // traffic. The test harness intentionally omits several production-mandatory diff --git a/KC-Core/pkg/scaci/server_test.go b/KC-Core/pkg/scaci/server_test.go index 752ec2c..6ab0c3f 100644 --- a/KC-Core/pkg/scaci/server_test.go +++ b/KC-Core/pkg/scaci/server_test.go @@ -12,11 +12,14 @@ package scaci import ( + "context" "testing" bsscitest "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci/testutil" "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/propagation" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -260,3 +263,61 @@ func TestNewServer_NilStatusSvc(t *testing.T) { require.Error(t, err, "NewServer should reject nil statusSvc") assert.Contains(t, err.Error(), "statusSvc is required") } + +// Minimal stubs for the trailing constructor dependencies so each nil check +// past statusSvc can be isolated. +type stubOrgDirectory struct{} + +func (stubOrgDirectory) GetDefaultOrgForTenant(_ context.Context, _ int64) (uuid.UUID, error) { + return uuid.Nil, nil +} + +type stubSnapshotSource struct{} + +func (stubSnapshotSource) ConnectedSessionsSnapshot() []propagation.BaseStationSession { return nil } + +type stubEndpointPropagator struct{} + +func (stubEndpointPropagator) TriggerEndpointPropagate(_ context.Context, _ int64, _ []propagation.BaseStationSession) error { + return nil +} + +// newServerArgsThroughPersistence returns the valid leading arguments up to +// and including sessionPersistence for the trailing nil-check tests. +func newServerThroughPersistence(orgResolver OrganizationDirectory, snapshot SessionSnapshotSource, propagator EndpointPropagator, recorder ErrorRecorder) (*Server, error) { + cfg := &Config{ListenAddr: ":5001"} + return NewServer(cfg, testLogger(), + &mockSessionRepoStub{}, &mockOperationRepoStub{}, + &MockHandshakeService{}, &MockEndpointService{}, &MockULService{}, &MockDLService{}, + &MockStatusService{}, &MockSessionValidator{}, &MockOperationRecorder{}, &MockSessionPersistence{}, + orgResolver, snapshot, propagator, recorder) +} + +// TestNewServer_NilOrgResolver verifies constructor rejects nil orgResolver. +func TestNewServer_NilOrgResolver(t *testing.T) { + _, err := newServerThroughPersistence(nil, nil, nil, nil) + require.Error(t, err, "NewServer should reject nil orgResolver") + assert.Contains(t, err.Error(), "orgResolver is required") +} + +// TestNewServer_NilSessionSnapshotProvider verifies constructor rejects nil +// sessionSnapshotProvider. +func TestNewServer_NilSessionSnapshotProvider(t *testing.T) { + _, err := newServerThroughPersistence(stubOrgDirectory{}, nil, nil, nil) + require.Error(t, err, "NewServer should reject nil sessionSnapshotProvider") + assert.Contains(t, err.Error(), "sessionSnapshotProvider is required") +} + +// TestNewServer_NilPropagationSvc verifies constructor rejects nil propagationSvc. +func TestNewServer_NilPropagationSvc(t *testing.T) { + _, err := newServerThroughPersistence(stubOrgDirectory{}, stubSnapshotSource{}, nil, nil) + require.Error(t, err, "NewServer should reject nil propagationSvc") + assert.Contains(t, err.Error(), "propagationSvc is required") +} + +// TestNewServer_NilErrorRecorder verifies constructor rejects nil errorRecorder. +func TestNewServer_NilErrorRecorder(t *testing.T) { + _, err := newServerThroughPersistence(stubOrgDirectory{}, stubSnapshotSource{}, stubEndpointPropagator{}, nil) + require.Error(t, err, "NewServer should reject nil errorRecorder") + assert.Contains(t, err.Error(), "errorRecorder is required") +} From eab22c4fab85bf52bc73ccc85bbcc82bc6a47491 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 20:51:29 +0200 Subject: [PATCH 20/25] fix(bssci): wrap attach-transaction errors instead of annotating bare returns The pass-through comments claimed handlers match these errors via errors.Is; neither consumer does - the attach handler maps every persistence failure to the database catalog error, and wrapping with %w would preserve errors.Is regardless. Every return now wraps with the corresponding catalog log-message prefix, proven by injected-sentinel tests. --- .../endpoint_attachment_persistence_test.go | 3 +- .../bssci/endpoint_attachment_service.go | 12 +++--- ...dpoint_attachment_service_sentinel_test.go | 42 +++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 KC-Core/internal/services/bssci/endpoint_attachment_service_sentinel_test.go diff --git a/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go b/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go index 508ab34..6fd08f7 100644 --- a/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go +++ b/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go @@ -109,7 +109,8 @@ func TestPersistAttachSession_CreatesSessionAndCommits(t *testing.T) { } // TestPersistAttachSession_UpdateFailureRollsBack: an endpoint update failure -// rolls the transaction back and propagates the raw error. +// rolls the transaction back; the wrapped error keeps the cause matchable +// via errors.Is. func TestPersistAttachSession_UpdateFailureRollsBack(t *testing.T) { st, tx := newPersistFixture() updateErr := errors.New("update failed") diff --git a/KC-Core/internal/services/bssci/endpoint_attachment_service.go b/KC-Core/internal/services/bssci/endpoint_attachment_service.go index ab7dae4..d453f99 100644 --- a/KC-Core/internal/services/bssci/endpoint_attachment_service.go +++ b/KC-Core/internal/services/bssci/endpoint_attachment_service.go @@ -420,7 +420,7 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context tx, txErr := p.storage.BeginTx(ctx) if txErr != nil { p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToBeginTransaction, "error", txErr) - return txErr + return fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToBeginTransaction, txErr) } var commitErr error @@ -433,7 +433,7 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if err := tx.EndPoints().UpdateFields(ctx, rec.TenantID, rec.EndpointID, rec.EndpointUpdates); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateEndpointAttachMetadata, "error", err) - return err // Preserves sentinel: handlers match repository errors via errors.Is + return fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToUpdateEndpointAttachMetadata, err) } endpointIDStr := fmt.Sprintf("%d", rec.EndpointID) @@ -441,7 +441,7 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if getErr != nil { commitErr = getErr p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToLoadEndpointSession, "error", getErr) - return getErr // Preserves sentinel: handlers match repository errors via errors.Is + return fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToLoadEndpointSession, getErr) } now := time.Now().UTC() @@ -468,7 +468,7 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if err := tx.EndPointSessions().Update(ctx, activeSession); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateEndpointSession, "error", err) - return err // Preserves sentinel: handlers match repository errors via errors.Is + return fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToUpdateEndpointSession, err) } } else { newSession := &models.EndPointSession{ @@ -487,14 +487,14 @@ func (p *endpointAttachmentPersistence) PersistAttachSession(ctx context.Context if err := tx.EndPointSessions().Create(ctx, newSession); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToCreateEndpointSession, "error", err) - return err // Preserves sentinel: handlers match repository errors via errors.Is + return fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToCreateEndpointSession, err) } } if err := tx.Commit(); err != nil { commitErr = err p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToCommitAttachTransaction, "error", err) - return err // Preserves sentinel: handlers match repository errors via errors.Is + return fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToCommitAttachTransaction, err) } commitErr = nil return nil diff --git a/KC-Core/internal/services/bssci/endpoint_attachment_service_sentinel_test.go b/KC-Core/internal/services/bssci/endpoint_attachment_service_sentinel_test.go new file mode 100644 index 0000000..a5f7d9b --- /dev/null +++ b/KC-Core/internal/services/bssci/endpoint_attachment_service_sentinel_test.go @@ -0,0 +1,42 @@ +package bssciservices + +import ( + "testing" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/bssci" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/logger" + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" + "github.com/Kiloiot/kilo-service-center/KC-DB/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPersistAttachSession_WrappersPreserveSentinels: every contextual %w +// wrapper on the attach transaction keeps repository sentinels matchable via +// errors.Is while adding the catalog log-message prefix. +func TestPersistAttachSession_WrappersPreserveSentinels(t *testing.T) { + t.Run("begin failure", func(t *testing.T) { + st, _ := newPersistFixture() + st.beginErr = storage.ErrNotFound + p := NewEndpointAttachmentPersistence(st, nil, logger.NewNop()) + + err := p.PersistAttachSession(testutil.TestContext(), bssci.AttachSessionRecord{TenantID: 1, EndpointID: 1}) + + require.ErrorIs(t, err, storage.ErrNotFound, + "the storage sentinel must survive the wrapper") + assert.Contains(t, err.Error(), bssci.LogBSSCIFailedToBeginTransaction, + "the wrapper must carry the catalog log-message prefix") + }) + + t.Run("endpoint update failure", func(t *testing.T) { + st, tx := newPersistFixture() + tx.endpoints.updateErr = storage.ErrNotFound + p := NewEndpointAttachmentPersistence(st, nil, logger.NewNop()) + + err := p.PersistAttachSession(testutil.TestContext(), bssci.AttachSessionRecord{TenantID: 1, EndpointID: 1}) + + require.ErrorIs(t, err, storage.ErrNotFound) + assert.Contains(t, err.Error(), bssci.LogBSSCIFailedToUpdateEndpointAttachMetadata) + assert.True(t, tx.rolledBack, "failed transaction must roll back") + }) +} From 0164fd69a124a7c2db15c16b4a18f56b45defd13 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 20:51:29 +0200 Subject: [PATCH 21/25] ci: full KC-Core suite, branch-push checks, images gated on the postgres suite KC-Core drops -short (the full suite runs in minutes and covers the resume/lifecycle integration tests), lint/test/test-postgres run on every branch push so pushed branches get check runs, and every image job now needs test-postgres and publishes only from main/dev (or a manual dispatch) - broken database code can no longer publish images while the PostgreSQL suite is still running. --- .github/workflows/pipeline-build.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pipeline-build.yml b/.github/workflows/pipeline-build.yml index e262758..191e290 100644 --- a/.github/workflows/pipeline-build.yml +++ b/.github/workflows/pipeline-build.yml @@ -4,8 +4,7 @@ on: workflow_dispatch: push: branches: - - main - - dev + - "**" tags-ignore: - "v*" pull_request: @@ -73,8 +72,8 @@ jobs: go-version-file: go.work - name: Run Go tests (unit only, no DB/infra) run: | - echo "::group::Testing KC-Core" - (cd KC-Core && go test -short -timeout 5m ./...) + echo "::group::Testing KC-Core (full suite)" + (cd KC-Core && go test -timeout 10m ./...) echo "::endgroup::" echo "::group::Testing KC-DB (postgres suite runs in test-postgres)" (cd KC-DB && go test -short -timeout 3m $(go list ./... | grep -v /postgres)) @@ -100,7 +99,8 @@ jobs: (cd KC-DB && go test -timeout 25m ./storage/postgres/...) build-kc-core: - needs: [generate-version, lint, test] + needs: [generate-version, lint, test, test-postgres] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write @@ -121,7 +121,8 @@ jobs: tags: ${{ needs.generate-version.outputs.registry }}/kc-core:${{ needs.generate-version.outputs.version }} build-kc-gateway: - needs: [generate-version, lint, test] + needs: [generate-version, lint, test, test-postgres] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write @@ -142,7 +143,8 @@ jobs: tags: ${{ needs.generate-version.outputs.registry }}/kc-gateway:${{ needs.generate-version.outputs.version }} build-kc-identity: - needs: [generate-version, lint, test] + needs: [generate-version, lint, test, test-postgres] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write @@ -163,7 +165,8 @@ jobs: tags: ${{ needs.generate-version.outputs.registry }}/kc-identity:${{ needs.generate-version.outputs.version }} build-kc-web: - needs: [generate-version, lint, test] + needs: [generate-version, lint, test, test-postgres] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: write From e6c0e170aa3e838466e3e1ef79462bf7cbcc284c Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 21:02:09 +0200 Subject: [PATCH 22/25] chore(release): stamp manifest at 02c1841 for Codex round 2 --- pkg/version/release-manifest.json | 6 +++--- release/manifest.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/version/release-manifest.json b/pkg/version/release-manifest.json index f4c3ab1..e13a4fc 100644 --- a/pkg/version/release-manifest.json +++ b/pkg/version/release-manifest.json @@ -1,7 +1,7 @@ { - "version": "v1.2.0+7b00145", - "buildTime": "2026-07-22T17:30:40Z", - "gitCommit": "7b0014505900035518a45dbb86082273b3925991", + "version": "v1.2.0+02c1841", + "buildTime": "2026-07-22T19:02:09Z", + "gitCommit": "02c1841e577a9afecf250500fa75d51260dc928b", "gitBranch": "CHIRP-3538-bssci-version-negotiation-eui64", "buildUser": "timkrav", "goVersion": "go1.26.4", diff --git a/release/manifest.json b/release/manifest.json index f4c3ab1..e13a4fc 100644 --- a/release/manifest.json +++ b/release/manifest.json @@ -1,7 +1,7 @@ { - "version": "v1.2.0+7b00145", - "buildTime": "2026-07-22T17:30:40Z", - "gitCommit": "7b0014505900035518a45dbb86082273b3925991", + "version": "v1.2.0+02c1841", + "buildTime": "2026-07-22T19:02:09Z", + "gitCommit": "02c1841e577a9afecf250500fa75d51260dc928b", "gitBranch": "CHIRP-3538-bssci-version-negotiation-eui64", "buildUser": "timkrav", "goVersion": "go1.26.4", From c9efdb9618771af93a507c1a20332b027763e1eb Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 21:18:49 +0200 Subject: [PATCH 23/25] ci: provide the migrated env database the schema-ownership tests require SetupEnvDBOrSkip fail-fasts under CI=true, so the postgres job now runs a migrated postgres:17.5 service on 5433 alongside the testcontainers suites - the combination the local gates already run. --- .github/workflows/pipeline-build.yml | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline-build.yml b/.github/workflows/pipeline-build.yml index 191e290..2804756 100644 --- a/.github/workflows/pipeline-build.yml +++ b/.github/workflows/pipeline-build.yml @@ -85,15 +85,45 @@ jobs: done # The KC-DB postgres suite runs against real testcontainers-managed - # PostgreSQL instances; it takes ~15 minutes and runs in parallel with the - # image builds rather than gating them. + # PostgreSQL instances, plus a migrated service database for the + # env-DB-backed schema tests (SetupEnvDBOrSkip fail-fasts when CI=true). + # It takes ~15 minutes and gates image publication. test-postgres: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17.5 + env: + POSTGRES_PASSWORD: changeme + POSTGRES_USER: kilocenter + POSTGRES_DB: kilocenter + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DB_HOST: localhost + DB_PORT: 5433 + DB_NAME: kilocenter + DB_USER: kilocenter + DB_PASSWORD: changeme steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.work + - name: Install golang-migrate + run: | + go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + - name: Run database migrations + run: | + migrate -path KC-DB/migrations \ + -database "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable" \ + up - name: Run KC-DB postgres integration tests run: | (cd KC-DB && go test -timeout 25m ./storage/postgres/...) From 13c2dca9251e0afd1cf0656833b744de9fb53618 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Wed, 22 Jul 2026 23:02:45 +0200 Subject: [PATCH 24/25] chore(release): stamp manifest at c9efdb9 --- pkg/version/release-manifest.json | 6 +++--- release/manifest.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/version/release-manifest.json b/pkg/version/release-manifest.json index e13a4fc..1fa0d97 100644 --- a/pkg/version/release-manifest.json +++ b/pkg/version/release-manifest.json @@ -1,7 +1,7 @@ { - "version": "v1.2.0+02c1841", - "buildTime": "2026-07-22T19:02:09Z", - "gitCommit": "02c1841e577a9afecf250500fa75d51260dc928b", + "version": "v1.2.0+c9efdb9", + "buildTime": "2026-07-22T21:02:45Z", + "gitCommit": "c9efdb9618771af93a507c1a20332b027763e1eb", "gitBranch": "CHIRP-3538-bssci-version-negotiation-eui64", "buildUser": "timkrav", "goVersion": "go1.26.4", diff --git a/release/manifest.json b/release/manifest.json index e13a4fc..1fa0d97 100644 --- a/release/manifest.json +++ b/release/manifest.json @@ -1,7 +1,7 @@ { - "version": "v1.2.0+02c1841", - "buildTime": "2026-07-22T19:02:09Z", - "gitCommit": "02c1841e577a9afecf250500fa75d51260dc928b", + "version": "v1.2.0+c9efdb9", + "buildTime": "2026-07-22T21:02:45Z", + "gitCommit": "c9efdb9618771af93a507c1a20332b027763e1eb", "gitBranch": "CHIRP-3538-bssci-version-negotiation-eui64", "buildUser": "timkrav", "goVersion": "go1.26.4", From e3ea3c3f65cab3c6b3a7428184c40cf4c0407cf5 Mon Sep 17 00:00:00 2001 From: wirelesstim Date: Thu, 23 Jul 2026 10:38:21 +0200 Subject: [PATCH 25/25] refactor: split CodeFactor-flagged complex methods into focused helpers --- KC-Core/pkg/bssci/message_normalizer.go | 269 ++++++----------- KC-Core/pkg/bssci/ul_transmit.go | 224 +++++++++------ KC-Core/pkg/scaci/server.go | 76 ++--- KC-DB/storage/postgres/archival.go | 160 +++++++---- .../basestation_session_repository.go | 145 ++++------ .../internal/proxy/verification_test.go | 264 +++++++---------- .../modules/users/pages/OrganizationUsers.tsx | 271 +++++++++++------- 7 files changed, 681 insertions(+), 728 deletions(-) diff --git a/KC-Core/pkg/bssci/message_normalizer.go b/KC-Core/pkg/bssci/message_normalizer.go index 855b45d..4026d8b 100644 --- a/KC-Core/pkg/bssci/message_normalizer.go +++ b/KC-Core/pkg/bssci/message_normalizer.go @@ -466,214 +466,123 @@ func coerceInt64(value interface{}) (int64, error) { } } -// coerceUint64 converts wire numeric values to uint64 with exact semantics. -// Unsigned integer widths are preserved exactly (the full EUI-64 range, -// including values above INT64_MAX, survives); negative values, non-integral +// coerceToUnsigned converts wire numeric values to an unsigned integer capped +// at targetMax with exact semantics. Unsigned integer widths are preserved +// exactly (the full EUI-64 range, including values above INT64_MAX, survives a +// MaxUint64 target); negative values, out-of-range magnitudes, non-integral // floats, and float magnitudes beyond the exact-integer range are rejected. -func coerceUint64(value interface{}) (uint64, error) { +// typeName names the target width in error messages. +func coerceToUnsigned(value interface{}, targetMax uint64, typeName string) (uint64, error) { switch v := value.(type) { case uint64: - return v, nil + return unsignedToTarget(v, targetMax, typeName) case uint: - return uint64(v), nil + return unsignedToTarget(uint64(v), targetMax, typeName) case uint32: - return uint64(v), nil + return unsignedToTarget(uint64(v), targetMax, typeName) case uint16: - return uint64(v), nil + return unsignedToTarget(uint64(v), targetMax, typeName) case uint8: - return uint64(v), nil + return unsignedToTarget(uint64(v), targetMax, typeName) case int64: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %d", v) - } - return uint64(v), nil + return signedToTarget(v, math.MaxInt64, targetMax, typeName) case int: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %d", v) - } - return uint64(v), nil + return signedToTarget(int64(v), math.MaxInt64, targetMax, typeName) case int32: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %d", v) - } - return uint64(v), nil + return signedToTarget(int64(v), math.MaxInt32, targetMax, typeName) case int16: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %d", v) - } - return uint64(v), nil + return signedToTarget(int64(v), math.MaxInt16, targetMax, typeName) case int8: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %d", v) - } - return uint64(v), nil + return signedToTarget(int64(v), math.MaxInt8, targetMax, typeName) case float64: - if err := checkExactIntegerFloat(v, maxExactFloat64Integer); err != nil { - return 0, err - } - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %v", v) - } - return uint64(v), nil + return floatToTarget(v, maxExactFloat64Integer, targetMax, typeName) case float32: - f := float64(v) - if err := checkExactIntegerFloat(f, maxExactFloat32Integer); err != nil { - return 0, err + return floatToTarget(float64(v), maxExactFloat32Integer, targetMax, typeName) + case json.Number: + return jsonNumberToTarget(v, targetMax, typeName) + default: + return 0, fmt.Errorf("cannot convert %T to %s", value, typeName) + } +} + +// unsignedToTarget range-checks an unsigned wire value against the target width. +func unsignedToTarget(v, targetMax uint64, typeName string) (uint64, error) { + if v > targetMax { + return 0, fmt.Errorf("value %d out of range for %s", v, typeName) + } + return v, nil +} + +// signedToTarget converts a signed wire value to the target width. When the +// source type can exceed the target range, negatives and overflows share one +// range error; when every non-negative source value fits, only negatives can +// fail and get a dedicated error. +func signedToTarget(v int64, sourceMax, targetMax uint64, typeName string) (uint64, error) { + if sourceMax > targetMax { + if v < 0 || uint64(v) > targetMax { + return 0, fmt.Errorf("value %d out of range for %s", v, typeName) } - if f < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %v", f) + return uint64(v), nil + } + if v < 0 { + return 0, fmt.Errorf("negative value cannot be coerced to %s: %d", typeName, v) + } + return uint64(v), nil +} + +// floatToTarget converts a float wire value to the target width, requiring an +// exactly representable integer within exactLimit. Sub-uint64 targets fold the +// sign check into the range error; a MaxUint64 target can only fail on sign. +func floatToTarget(f float64, exactLimit, targetMax uint64, typeName string) (uint64, error) { + if err := checkExactIntegerFloat(f, exactLimit); err != nil { + return 0, err + } + if targetMax < math.MaxUint64 { + if f < 0 || f > float64(targetMax) { + return 0, fmt.Errorf("value %f out of range for %s", f, typeName) } return uint64(f), nil - case json.Number: - return jsonNumberToUint64(v) - default: - return 0, fmt.Errorf("cannot convert %T to uint64", value) } + if f < 0 { + return 0, fmt.Errorf("negative value cannot be coerced to %s: %v", typeName, f) + } + return uint64(f), nil +} + +// jsonNumberToTarget parses a json.Number and range-checks it against the +// target width. +func jsonNumberToTarget(v json.Number, targetMax uint64, typeName string) (uint64, error) { + u, err := jsonNumberToUint64(v) + if err != nil { + return 0, err + } + if u > targetMax { + return 0, fmt.Errorf("value %d out of range for %s", u, typeName) + } + return u, nil +} + +// coerceUint64 converts wire numeric values to uint64 with exact semantics. +func coerceUint64(value interface{}) (uint64, error) { + return coerceToUnsigned(value, math.MaxUint64, "uint64") } // coerceUint32 converts various numeric types to uint32 func coerceUint32(value interface{}) (uint32, error) { - switch v := value.(type) { - case uint32: - return v, nil - case uint16: - // uint16 always fits in uint32 (0-65535) - return uint32(v), nil - case uint64: - if v > uint64(math.MaxUint32) { - return 0, fmt.Errorf("value %d out of range for uint32", v) - } - return uint32(v), nil - case uint8: - // uint8 always fits in uint32 (0-255) - return uint32(v), nil - case int64: - if v < 0 || v > 4294967295 { - return 0, fmt.Errorf("value %d out of range for uint32", v) - } - return uint32(v), nil - case int: - if v < 0 || v > 4294967295 { - return 0, fmt.Errorf("value %d out of range for uint32", v) - } - return uint32(v), nil - case int32: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint32: %d", v) - } - return uint32(v), nil - case int16: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint32: %d", v) - } - return uint32(v), nil - case int8: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint32: %d", v) - } - return uint32(v), nil - case float64: - if err := checkExactIntegerFloat(v, maxExactFloat64Integer); err != nil { - return 0, err - } - if v < 0 || v > math.MaxUint32 { - return 0, fmt.Errorf("value %f out of range for uint32", v) - } - return uint32(v), nil - case float32: - f := float64(v) - if err := checkExactIntegerFloat(f, maxExactFloat32Integer); err != nil { - return 0, err - } - if f < 0 || f > math.MaxUint32 { - return 0, fmt.Errorf("value %f out of range for uint32", f) - } - return uint32(f), nil - case json.Number: - u, err := jsonNumberToUint64(v) - if err != nil { - return 0, err - } - if u > math.MaxUint32 { - return 0, fmt.Errorf("value %d out of range for uint32", u) - } - return uint32(u), nil - default: - return 0, fmt.Errorf("cannot convert %T to uint32", value) + u, err := coerceToUnsigned(value, math.MaxUint32, "uint32") + if err != nil { + return 0, err } + return uint32(u), nil // #nosec G115 -- coerceToUnsigned enforces targetMax } // coerceUint16 converts various numeric types to uint16 func coerceUint16(value interface{}) (uint16, error) { - switch v := value.(type) { - case uint16: - return v, nil - case uint64: - if v > uint64(math.MaxUint16) { - return 0, fmt.Errorf("value %d out of range for uint16", v) - } - return uint16(v), nil - case uint32: - if v > uint32(math.MaxUint16) { - return 0, fmt.Errorf("value %d out of range for uint16", v) - } - return uint16(v), nil - case uint8: - return uint16(v), nil - case int64: - if v < 0 || v > 65535 { - return 0, fmt.Errorf("value %d out of range for uint16", v) - } - return uint16(v), nil - case int: - if v < 0 || v > 65535 { - return 0, fmt.Errorf("value %d out of range for uint16", v) - } - return uint16(v), nil - case int32: - if v < 0 || v > 65535 { - return 0, fmt.Errorf("value %d out of range for uint16", v) - } - return uint16(v), nil - case int16: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint16: %d", v) - } - return uint16(v), nil - case int8: - if v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint16: %d", v) - } - return uint16(v), nil - case float64: - if err := checkExactIntegerFloat(v, maxExactFloat64Integer); err != nil { - return 0, err - } - if v < 0 || v > math.MaxUint16 { - return 0, fmt.Errorf("value %f out of range for uint16", v) - } - return uint16(v), nil - case float32: - f := float64(v) - if err := checkExactIntegerFloat(f, maxExactFloat32Integer); err != nil { - return 0, err - } - if f < 0 || f > math.MaxUint16 { - return 0, fmt.Errorf("value %f out of range for uint16", f) - } - return uint16(f), nil - case json.Number: - u, err := jsonNumberToUint64(v) - if err != nil { - return 0, err - } - if u > math.MaxUint16 { - return 0, fmt.Errorf("value %d out of range for uint16", u) - } - return uint16(u), nil - default: - return 0, fmt.Errorf("cannot convert %T to uint16", value) + u, err := coerceToUnsigned(value, math.MaxUint16, "uint16") + if err != nil { + return 0, err } + return uint16(u), nil // #nosec G115 -- coerceToUnsigned enforces targetMax } // coerceFloat64 converts various numeric types to float64. diff --git a/KC-Core/pkg/bssci/ul_transmit.go b/KC-Core/pkg/bssci/ul_transmit.go index 554fcd3..a84fc82 100644 --- a/KC-Core/pkg/bssci/ul_transmit.go +++ b/KC-Core/pkg/bssci/ul_transmit.go @@ -72,113 +72,169 @@ var ( func (s *Server) SendULDataTransmit(sessionID string, epEui uint64, nwkSnKey []byte, shAddr uint16, packetCnt uint32, userData []byte, profile string, format uint8) (int64, error) { + session, err := s.validateULTransmitSession(sessionID, nwkSnKey) + if err != nil { + return 0, err + } + + // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist + // the counter, persist the pending record, then write the frame. The + // counter is never rolled back. + opId, err := s.beginScOperation(session) + if err != nil { + return 0, err + } + + encryptedKeyForStorage, isEncrypted, err := s.encryptULKeyForStorage(session, sessionID, nwkSnKey) + if err != nil { + return 0, err + } + + msg := buildULDataTxMessage(opId, epEui, nwkSnKey, userData, shAddr, packetCnt, profile, format) + + // Create EUI bytes for storage + euiBytes := make([]byte, 8) + binary.BigEndian.PutUint64(euiBytes, epEui) + + metadata := buildULDataTxMetadata(userData, shAddr, packetCnt, profile, format, + encryptedKeyForStorage, isEncrypted) + sanitizedMsg := buildSanitizedULDataTxMessage(opId, epEui, shAddr, packetCnt, profile, format) + + // The recovery record must be durable before the frame is written; a + // persistence failure aborts the send, leaving only a consumed-ID gap. + if err := s.persistPendingOperation(session, opId, mioty.CmdULDataTransmit, + sanitizedMsg, euiBytes, metadata); err != nil { + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistULDataTxOperation, + "sessionID", sessionID, + "opId", opId, + "error", err) + return 0, err + } + + s.logger.InfoContext(s.sessionContext(session), LogBSSCISendingULDataTransmit, + "sessionID", sessionID, + "opId", opId, + "epEui", epEui, + "packetCnt", packetCnt, + "shAddr", shAddr, + "userDataLen", len(userData)) + + // Send message with CLEAR key to base station + if err := s.sendMessage(session, msg); err != nil { + s.handleULDataTxSendFailure(session, opId, err) + return 0, err + } + + return opId, nil +} + +// validateULTransmitSession resolves the session and checks the §3.11 +// preconditions: handshake completion (BSSCI-3.3-03), bidirectional support, +// and network session key length. +func (s *Server) validateULTransmitSession(sessionID string, nwkSnKey []byte) (*Session, error) { s.mu.RLock() session, exists := s.sessions[sessionID] s.mu.RUnlock() if !exists { - return 0, fmt.Errorf("%s: %s", ResolveErrorMessage(errSessionNotFound), sessionID) + return nil, fmt.Errorf("%s: %s", ResolveErrorMessage(errSessionNotFound), sessionID) } - // Check if connect handshake is complete (BSSCI-3.3-03) if !session.HandshakeComplete { s.logger.WarnContext(s.sessionContext(session), LogBSSCIConnectHandshakeNotComplete, "sessionID", sessionID, "bsEui", session.BaseStationEUI) - return 0, fmt.Errorf("%s for session %s", ResolveErrorMessage(errHandshakeNotComplete), sessionID) + return nil, fmt.Errorf("%s for session %s", ResolveErrorMessage(errHandshakeNotComplete), sessionID) } - // Check if base station supports bidirectional communication if !session.Bidirectional { s.logger.WarnContext(s.sessionContext(session), LogBSSCIBaseStationDoesNotSupportBidi, "sessionID", sessionID, "bsEui", session.BaseStationEUI) - return 0, fmt.Errorf("%s %016X", ResolveErrorMessage(errBaseStationNotBidirectional), session.BaseStationEUI) + return nil, fmt.Errorf("%s %016X", ResolveErrorMessage(errBaseStationNotBidirectional), session.BaseStationEUI) } - // Validate key length if len(nwkSnKey) != 16 { - return 0, fmt.Errorf("%s, got %d", ResolveErrorMessage(errNwkSnKeyInvalidLength), len(nwkSnKey)) + return nil, fmt.Errorf("%s, got %d", ResolveErrorMessage(errNwkSnKeyInvalidLength), len(nwkSnKey)) } - // Durable order (BSSCI rev1 §5.2 / classic §3.2): allocate the ID, persist - // the counter, persist the pending record, then write the frame. The - // counter is never rolled back. - opId, err := s.beginScOperation(session) - if err != nil { - return 0, err - } + return session, nil +} - // Encrypt key for storage ONLY (not for transmission) - var encryptedKeyForStorage string - isEncrypted := false // Track whether the key was successfully encrypted - if s.keyEncryptor != nil { - encrypted, err := s.keyEncryptor.EncryptKey(nwkSnKey) - if err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToEncryptNetworkSessionKey, - "error", err, - "sessionID", sessionID) - // Production: fail operation if encryption fails (no plaintext fallback) - return 0, fmt.Errorf("failed to encrypt network session key: %w", err) - } - encryptedKeyForStorage = encrypted - isEncrypted = true - } else { - // No encryptor available - only allowed in dev mode with KILOCENTER_ALLOW_PLAINTEXT_KEYS=true - // (NewServer returns nil if encryptor init fails in production) - encryptedKeyForStorage = base64.StdEncoding.EncodeToString(nwkSnKey) - isEncrypted = false +// encryptULKeyForStorage encrypts the network session key for persistence +// ONLY (never for transmission). Without an encryptor the key is stored +// base64-encoded, which is only allowed in dev mode with +// KILOCENTER_ALLOW_PLAINTEXT_KEYS=true (NewServer returns nil if encryptor +// init fails in production). +func (s *Server) encryptULKeyForStorage(session *Session, sessionID string, nwkSnKey []byte) (string, bool, error) { + if s.keyEncryptor == nil { + return base64.StdEncoding.EncodeToString(nwkSnKey), false, nil } - // Convert CLEAR key to Numeric[16] array for wire protocol per MIOTY spec - nwkSnKeyArray := make([]interface{}, 16) - for i := 0; i < 16; i++ { - nwkSnKeyArray[i] = uint8(nwkSnKey[i]) + encrypted, err := s.keyEncryptor.EncryptKey(nwkSnKey) + if err != nil { + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToEncryptNetworkSessionKey, + "error", err, + "sessionID", sessionID) + // Production: fail operation if encryption fails (no plaintext fallback) + return "", false, fmt.Errorf("failed to encrypt network session key: %w", err) } + return encrypted, true, nil +} - // Convert userData to Numeric[n] array for wire protocol (BSSCI-3.11.1) - userDataArray := make([]interface{}, len(userData)) - for i := 0; i < len(userData); i++ { - userDataArray[i] = uint8(userData[i]) +// bytesToNumericArray converts raw bytes to the Numeric[n] array shape the +// BSSCI wire protocol expects. +func bytesToNumericArray(data []byte) []interface{} { + arr := make([]interface{}, len(data)) + for i := range data { + arr[i] = uint8(data[i]) } + return arr +} - // Build ulDataTx message per MIOTY BSSCI spec Section 3.11.1 +// buildULDataTxMessage assembles the ulDataTx wire message per MIOTY BSSCI +// spec Section 3.11.1, carrying the CLEAR key as a Numeric[16] array. +func buildULDataTxMessage(opId int64, epEui uint64, nwkSnKey, userData []byte, + shAddr uint16, packetCnt uint32, profile string, format uint8) map[string]interface{} { msg := map[string]interface{}{ "command": mioty.CmdULDataTransmit, "opId": opId, - "epEui": epEui, // Numeric[8] per spec - "nwkSnKey": nwkSnKeyArray, // CLEAR key as Numeric[16] array - "shAddr": shAddr, // uint16 per spec - "packetCnt": packetCnt, // uint32 per spec - "userData": userDataArray, // Numeric[n] array per spec + "epEui": epEui, // Numeric[8] per spec + "nwkSnKey": bytesToNumericArray(nwkSnKey), // CLEAR key as Numeric[16] array + "shAddr": shAddr, // uint16 per spec + "packetCnt": packetCnt, // uint32 per spec + "userData": bytesToNumericArray(userData), // Numeric[n] array per spec } - - // Add optional fields if format > 0 { msg["format"] = format } if profile != "" { msg["profile"] = profile } + return msg +} - // Create EUI bytes for storage - euiBytes := make([]byte, 8) - binary.BigEndian.PutUint64(euiBytes, epEui) - - // Store in pendingOps with encrypted key and userData in metadata +// buildULDataTxMetadata builds the pending-operation metadata carrying the +// encrypted key and userData. +func buildULDataTxMetadata(userData []byte, shAddr uint16, packetCnt uint32, + profile string, format uint8, encryptedKey string, isEncrypted bool) map[string]interface{} { userDataB64 := base64.StdEncoding.EncodeToString(userData) - metadata := map[string]interface{}{ + return map[string]interface{}{ "packetCnt": packetCnt, "shAddr": shAddr, "format": format, "profile": profile, - "encryptedKey": encryptedKeyForStorage, // Already base64 encoded - "isEncrypted": isEncrypted, // Track encryption status + "encryptedKey": encryptedKey, // Already base64 encoded + "isEncrypted": isEncrypted, // Track encryption status "userData": userDataB64, "data": userDataB64, // Also store as "data" for PendingOperation.Data population } +} - // Create sanitized message for persistence (no raw keys) +// buildSanitizedULDataTxMessage builds the persistence copy of the ulDataTx +// message without the raw key, which lives only in encrypted metadata. +func buildSanitizedULDataTxMessage(opId int64, epEui uint64, shAddr uint16, + packetCnt uint32, profile string, format uint8) map[string]interface{} { sanitizedMsg := map[string]interface{}{ "command": mioty.CmdULDataTransmit, "opId": opId, @@ -186,48 +242,30 @@ func (s *Server) SendULDataTransmit(sessionID string, epEui uint64, nwkSnKey []b "shAddr": shAddr, "packetCnt": packetCnt, "format": format, - // NO nwkSnKey here - it's in encrypted metadata } if profile != "" { sanitizedMsg["profile"] = profile } + return sanitizedMsg +} - // The recovery record must be durable before the frame is written; a - // persistence failure aborts the send, leaving only a consumed-ID gap. - if err := s.persistPendingOperation(session, opId, mioty.CmdULDataTransmit, - sanitizedMsg, euiBytes, metadata); err != nil { - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToPersistULDataTxOperation, - "sessionID", sessionID, +// handleULDataTxSendFailure reconciles the pending operation after a failed +// frame write: an ambiguous write keeps the pending row for resume reissue and +// closes the transport, while a clean failure removes the recovery row. +func (s *Server) handleULDataTxSendFailure(session *Session, opId int64, err error) { + if errors.Is(err, ErrAmbiguousWrite) { + // The frame may be partially on the wire: keep the pending row for + // resume reissue with the original ID and close the transport. + s.closeTransportAfterWriteFailure(session, opId, err) + return + } + if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { + // Nothing reached the wire; the recovery row is removed. + s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationAfterSendFailure, + "sessionID", session.DbSessionID, "opId", opId, - "error", err) - return 0, err + "error", cleanupErr) } - - s.logger.InfoContext(s.sessionContext(session), LogBSSCISendingULDataTransmit, - "sessionID", sessionID, - "opId", opId, - "epEui", epEui, - "packetCnt", packetCnt, - "shAddr", shAddr, - "userDataLen", len(userData)) - - // Send message with CLEAR key to base station - if err := s.sendMessage(session, msg); err != nil { - if errors.Is(err, ErrAmbiguousWrite) { - // The frame may be partially on the wire: keep the pending row for - // resume reissue with the original ID and close the transport. - s.closeTransportAfterWriteFailure(session, opId, err) - } else if cleanupErr := s.removePendingOperation(session, opId); cleanupErr != nil { - // Nothing reached the wire; the recovery row is removed. - s.logger.ErrorContext(s.sessionContext(session), LogBSSCIFailedToRemovePendingOperationAfterSendFailure, - "sessionID", session.DbSessionID, - "opId", opId, - "error", cleanupErr) - } - return 0, err - } - - return opId, nil } // handleULDataTxResponse handles the base station's response to ulDataTx diff --git a/KC-Core/pkg/scaci/server.go b/KC-Core/pkg/scaci/server.go index d91def5..725e15d 100644 --- a/KC-Core/pkg/scaci/server.go +++ b/KC-Core/pkg/scaci/server.go @@ -203,54 +203,34 @@ func NewServer( propagationSvc EndpointPropagator, errorRecorder ErrorRecorder, ) (*Server, error) { - // Validate required dependencies (fail-fast pattern per BSSCI server.go:641-644) - if cfg == nil { - return nil, fmt.Errorf("scaci.NewServer: cfg is required") - } - if log == nil { - return nil, fmt.Errorf("scaci.NewServer: logger is required") - } - if sessionRepo == nil { - return nil, fmt.Errorf("scaci.NewServer: sessionRepo is required (§3.3)") - } - if operationRepo == nil { - return nil, fmt.Errorf("scaci.NewServer: operationRepo is required (§3.2)") - } - if handshakeSvc == nil { - return nil, fmt.Errorf("scaci.NewServer: handshakeSvc is required (§3.3)") - } - if endpointSvc == nil { - return nil, fmt.Errorf("scaci.NewServer: endpointSvc is required (§3.6-3.7)") - } - if ulSvc == nil { - return nil, fmt.Errorf("scaci.NewServer: ulSvc is required (§3.9)") - } - if dlSvc == nil { - return nil, fmt.Errorf("scaci.NewServer: dlSvc is required (§3.8)") - } - if statusSvc == nil { - return nil, fmt.Errorf("scaci.NewServer: statusSvc is required (§3.5)") - } - if sessionValidator == nil { - return nil, fmt.Errorf("scaci.NewServer: sessionValidator is required (§3.3.1)") - } - if operationRecorder == nil { - return nil, fmt.Errorf("scaci.NewServer: operationRecorder is required") - } - if sessionPersistence == nil { - return nil, fmt.Errorf("scaci.NewServer: sessionPersistence is required") - } - if orgResolver == nil { - return nil, fmt.Errorf("scaci.NewServer: orgResolver is required (org context parity)") - } - if sessionSnapshotProvider == nil { - return nil, fmt.Errorf("scaci.NewServer: sessionSnapshotProvider is required (§3.3)") - } - if propagationSvc == nil { - return nil, fmt.Errorf("scaci.NewServer: propagationSvc is required (§3.6-3.7)") - } - if errorRecorder == nil { - return nil, fmt.Errorf("scaci.NewServer: errorRecorder is required (§3.14)") + // Validate required dependencies (fail-fast pattern per BSSCI server.go:641-644). + // Presence is evaluated at the call site so typed-nil interface values are + // caught the same way plain nils are. + deps := []struct { + present bool + message string + }{ + {cfg != nil, "cfg is required"}, + {log != nil, "logger is required"}, + {sessionRepo != nil, "sessionRepo is required (§3.3)"}, + {operationRepo != nil, "operationRepo is required (§3.2)"}, + {handshakeSvc != nil, "handshakeSvc is required (§3.3)"}, + {endpointSvc != nil, "endpointSvc is required (§3.6-3.7)"}, + {ulSvc != nil, "ulSvc is required (§3.9)"}, + {dlSvc != nil, "dlSvc is required (§3.8)"}, + {statusSvc != nil, "statusSvc is required (§3.5)"}, + {sessionValidator != nil, "sessionValidator is required (§3.3.1)"}, + {operationRecorder != nil, "operationRecorder is required"}, + {sessionPersistence != nil, "sessionPersistence is required"}, + {orgResolver != nil, "orgResolver is required (org context parity)"}, + {sessionSnapshotProvider != nil, "sessionSnapshotProvider is required (§3.3)"}, + {propagationSvc != nil, "propagationSvc is required (§3.6-3.7)"}, + {errorRecorder != nil, "errorRecorder is required (§3.14)"}, + } + for _, dep := range deps { + if !dep.present { + return nil, fmt.Errorf("scaci.NewServer: %s", dep.message) + } } return &Server{ diff --git a/KC-DB/storage/postgres/archival.go b/KC-DB/storage/postgres/archival.go index 2b5e166..21e0e9b 100644 --- a/KC-DB/storage/postgres/archival.go +++ b/KC-DB/storage/postgres/archival.go @@ -387,9 +387,41 @@ func (s *ArchivalService) PurgeArchivedMessages(ctx context.Context, archivedBef func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, error) { stats := &ArchivalStats{} - // Get main table stats + if err := s.queryMainTableStats(ctx, stats); err != nil { + return nil, err + } + + canonicalBytes, oldestArchive, newestArchive, err := s.queryCanonicalArchiveStats(ctx, stats) + if err != nil { + return nil, err + } + + legacyBytes, oldestLegacy, newestLegacy, err := s.queryLegacyArchiveStats(ctx, stats) + if err != nil { + return nil, err + } + + // Combined totals across both archive tables + stats.ArchiveTableCount = stats.CanonicalArchiveCount + stats.LegacyArchiveCount + stats.CanonicalArchiveSize = prettyBytes(canonicalBytes) + stats.LegacyArchiveSize = prettyBytes(legacyBytes) + stats.ArchiveTableSize = prettyBytes(canonicalBytes + legacyBytes) + stats.OldestArchiveMessage = earliestValid(oldestArchive, oldestLegacy) + stats.NewestArchiveMessage = latestValid(newestArchive, newestLegacy) + + partitions, err := s.queryPartitionInfo(ctx) + if err != nil { + return nil, err + } + stats.Partitions = partitions + + return stats, nil +} + +// queryMainTableStats fills the main messages-table counters and timestamps. +func (s *ArchivalService) queryMainTableStats(ctx context.Context, stats *ArchivalStats) error { mainQuery := ` - SELECT + SELECT COUNT(*) as total_messages, COUNT(*) FILTER (WHERE archived = true) as archived_messages, MIN(received_at) as oldest_message, @@ -406,7 +438,7 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, &stats.MainTableSize, ) if err != nil { - return nil, fmt.Errorf("get main table stats: %w", err) + return fmt.Errorf("get main table stats: %w", err) } if oldestMain.Valid { @@ -415,9 +447,13 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, if newestMain.Valid { stats.NewestMainMessage = &newestMain.Time } + return nil +} - // Canonical archive stats (raw byte sizes so combined totals can be - // summed numerically; pg_size_pretty strings cannot be added) +// queryCanonicalArchiveStats reads the canonical archive table. Raw byte sizes +// are returned so combined totals can be summed numerically; pg_size_pretty +// strings cannot be added. +func (s *ArchivalService) queryCanonicalArchiveStats(ctx context.Context, stats *ArchivalStats) (tableBytes int64, oldest, newest sql.NullTime, err error) { archiveQuery := ` SELECT COUNT(*) as total_messages, @@ -426,71 +462,56 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, pg_total_relation_size('messages_archive') as table_bytes FROM messages_archive` - var oldestArchive, newestArchive sql.NullTime - var canonicalBytes int64 err = s.db.QueryRowContext(ctx, archiveQuery).Scan( &stats.CanonicalArchiveCount, - &oldestArchive, - &newestArchive, - &canonicalBytes, + &oldest, + &newest, + &tableBytes, ) if err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("get archive table stats: %w", err) + return 0, sql.NullTime{}, sql.NullTime{}, fmt.Errorf("get archive table stats: %w", err) } + return tableBytes, oldest, newest, nil +} - // Legacy archive stats: the pre-000139 table preserves rows the canonical - // rebuild could not losslessly project. It is optional (to_regclass) and - // combined statistics cover both tables. - var legacyBytes int64 +// queryLegacyArchiveStats reads the pre-000139 archive table, which preserves +// rows the canonical rebuild could not losslessly project. It is optional +// (to_regclass) and combined statistics cover both tables. +func (s *ArchivalService) queryLegacyArchiveStats(ctx context.Context, stats *ArchivalStats) (tableBytes int64, oldest, newest sql.NullTime, err error) { var legacyExists *string if err := s.db.QueryRowContext(ctx, `SELECT to_regclass('messages_archive_pre000139')::text`).Scan(&legacyExists); err != nil { - return nil, fmt.Errorf("check legacy archive presence: %w", err) - } - var oldestLegacy, newestLegacy sql.NullTime - if legacyExists != nil { - legacyQuery := ` - SELECT - COUNT(*) as total_messages, - MIN(received_at) as oldest_message, - MAX(received_at) as newest_message, - pg_total_relation_size('messages_archive_pre000139') as table_bytes - FROM messages_archive_pre000139` - if err := s.db.QueryRowContext(ctx, legacyQuery).Scan( - &stats.LegacyArchiveCount, - &oldestLegacy, - &newestLegacy, - &legacyBytes, - ); err != nil && err != sql.ErrNoRows { - return nil, fmt.Errorf("get legacy archive stats: %w", err) - } + return 0, sql.NullTime{}, sql.NullTime{}, fmt.Errorf("check legacy archive presence: %w", err) } - - // Combined totals across both archive tables - stats.ArchiveTableCount = stats.CanonicalArchiveCount + stats.LegacyArchiveCount - stats.CanonicalArchiveSize = prettyBytes(canonicalBytes) - stats.LegacyArchiveSize = prettyBytes(legacyBytes) - stats.ArchiveTableSize = prettyBytes(canonicalBytes + legacyBytes) - - // Oldest/newest across both tables - for _, cand := range []sql.NullTime{oldestArchive, oldestLegacy} { - if cand.Valid && (stats.OldestArchiveMessage == nil || cand.Time.Before(*stats.OldestArchiveMessage)) { - t := cand.Time - stats.OldestArchiveMessage = &t - } + if legacyExists == nil { + return 0, sql.NullTime{}, sql.NullTime{}, nil } - for _, cand := range []sql.NullTime{newestArchive, newestLegacy} { - if cand.Valid && (stats.NewestArchiveMessage == nil || cand.Time.After(*stats.NewestArchiveMessage)) { - t := cand.Time - stats.NewestArchiveMessage = &t - } + + legacyQuery := ` + SELECT + COUNT(*) as total_messages, + MIN(received_at) as oldest_message, + MAX(received_at) as newest_message, + pg_total_relation_size('messages_archive_pre000139') as table_bytes + FROM messages_archive_pre000139` + err = s.db.QueryRowContext(ctx, legacyQuery).Scan( + &stats.LegacyArchiveCount, + &oldest, + &newest, + &tableBytes, + ) + if err != nil && err != sql.ErrNoRows { + return 0, sql.NullTime{}, sql.NullTime{}, fmt.Errorf("get legacy archive stats: %w", err) } + return tableBytes, oldest, newest, nil +} - // Get partition info +// queryPartitionInfo lists the monthly message partitions with pretty sizes. +func (s *ArchivalService) queryPartitionInfo(ctx context.Context) ([]PartitionInfo, error) { partitionQuery := ` - SELECT + SELECT tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size - FROM pg_tables + FROM pg_tables WHERE tablename LIKE 'messages_%' AND tablename != 'messages_archive' AND tablename != 'messages_archive_pre000139' @@ -506,16 +527,39 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, } }() - stats.Partitions = make([]PartitionInfo, 0) + partitions := make([]PartitionInfo, 0) for rows.Next() { var info PartitionInfo if err := rows.Scan(&info.Name, &info.Size); err != nil { return nil, fmt.Errorf("scan partition info: %w", err) } - stats.Partitions = append(stats.Partitions, info) + partitions = append(partitions, info) } + return partitions, nil +} - return stats, nil +// earliestValid returns the earliest valid candidate timestamp, or nil. +func earliestValid(candidates ...sql.NullTime) *time.Time { + var earliest *time.Time + for _, cand := range candidates { + if cand.Valid && (earliest == nil || cand.Time.Before(*earliest)) { + t := cand.Time + earliest = &t + } + } + return earliest +} + +// latestValid returns the latest valid candidate timestamp, or nil. +func latestValid(candidates ...sql.NullTime) *time.Time { + var latest *time.Time + for _, cand := range candidates { + if cand.Valid && (latest == nil || cand.Time.After(*latest)) { + t := cand.Time + latest = &t + } + } + return latest } // ArchivalStats contains statistics about archived messages. The combined diff --git a/KC-DB/storage/postgres/basestation_session_repository.go b/KC-DB/storage/postgres/basestation_session_repository.go index 01a137a..6fc17f6 100644 --- a/KC-DB/storage/postgres/basestation_session_repository.go +++ b/KC-DB/storage/postgres/basestation_session_repository.go @@ -176,102 +176,25 @@ func (r *BaseStationSessionRepository) UpdateSession(ctx context.Context, tenant return fmt.Errorf("update request cannot be nil") } - updates := []string{} - args := []interface{}{} - argPos := 1 - - if req.SnBsOpId != nil { - updates = append(updates, fmt.Sprintf("sn_bs_op_id = $%d", argPos)) - args = append(args, *req.SnBsOpId) - argPos++ - } - - if req.SnScOpId != nil { - updates = append(updates, fmt.Sprintf("sn_sc_op_id = $%d", argPos)) - args = append(args, *req.SnScOpId) - argPos++ - } - - if req.Status != nil { - updates = append(updates, fmt.Sprintf("status = $%d", argPos)) - args = append(args, *req.Status) - argPos++ - } - - if req.LastPingAt != nil { - updates = append(updates, fmt.Sprintf("last_ping_at = $%d", argPos)) - args = append(args, *req.LastPingAt) - argPos++ - } - - if req.EndedAt != nil && req.ClearEndedAt { - return fmt.Errorf("update request cannot set and clear ended_at at once") - } - - if req.EndedAt != nil { - updates = append(updates, fmt.Sprintf("ended_at = $%d", argPos)) - args = append(args, *req.EndedAt) - argPos++ - } - - if req.ClearEndedAt { - updates = append(updates, "ended_at = NULL") - } - - if req.CanResume != nil { - updates = append(updates, fmt.Sprintf("can_resume = $%d", argPos)) - args = append(args, *req.CanResume) - argPos++ - } - - if req.ConnectionId != nil { - updates = append(updates, fmt.Sprintf("connection_id = $%d", argPos)) - args = append(args, *req.ConnectionId) - argPos++ - } - - if req.RemoteAddr != nil { - updates = append(updates, fmt.Sprintf("remote_addr = $%d", argPos)) - args = append(args, *req.RemoteAddr) - argPos++ - } - - if req.OrganizationID != nil { - updates = append(updates, fmt.Sprintf("organization_id = $%d", argPos)) - args = append(args, *req.OrganizationID) - argPos++ - } - - if req.Encoding != nil { - updates = append(updates, fmt.Sprintf("encoding = $%d", argPos)) - args = append(args, *req.Encoding) - argPos++ - } - - if req.ProtocolVersion != nil { - updates = append(updates, fmt.Sprintf("protocol_version = $%d", argPos)) - args = append(args, *req.ProtocolVersion) - argPos++ + builder, err := buildSessionUpdateClauses(req) + if err != nil { + return err } - - if len(updates) == 0 { + if len(builder.clauses) == 0 { return fmt.Errorf("no fields to update") } - updates = append(updates, fmt.Sprintf("updated_at = $%d", argPos)) - args = append(args, time.Now()) - argPos++ - - args = append(args, sessionID, tenantID) + builder.set("updated_at", time.Now()) + builder.args = append(builder.args, sessionID, tenantID) query := fmt.Sprintf(` UPDATE basestation_sessions SET %s WHERE id = $%d AND tenant_id = $%d`, - strings.Join(updates, ", "), - argPos, argPos+1) + strings.Join(builder.clauses, ", "), + len(builder.args)-1, len(builder.args)) - result, err := r.db.ExecContext(ctx, query, args...) + result, err := r.db.ExecContext(ctx, query, builder.args...) if err != nil { return fmt.Errorf("failed to update Base Station session: %w", err) } @@ -288,6 +211,56 @@ func (r *BaseStationSessionRepository) UpdateSession(ctx context.Context, tenant return nil } +// updateSetBuilder accumulates SET clauses with positional placeholders for a +// dynamic partial UPDATE. +type updateSetBuilder struct { + clauses []string + args []interface{} +} + +// set appends a column assignment bound to the next positional argument. +func (b *updateSetBuilder) set(column string, value interface{}) { + b.args = append(b.args, value) + b.clauses = append(b.clauses, fmt.Sprintf("%s = $%d", column, len(b.args))) +} + +// setNull appends a literal NULL assignment for a column. +func (b *updateSetBuilder) setNull(column string) { + b.clauses = append(b.clauses, fmt.Sprintf("%s = NULL", column)) +} + +// setIfPresent appends a column assignment when the optional value is set. +func setIfPresent[T any](b *updateSetBuilder, column string, value *T) { + if value != nil { + b.set(column, *value) + } +} + +// buildSessionUpdateClauses maps the optional request fields onto SET clauses, +// rejecting a request that both sets and clears ended_at. +func buildSessionUpdateClauses(req *models.BaseStationSessionUpdateRequest) (*updateSetBuilder, error) { + if req.EndedAt != nil && req.ClearEndedAt { + return nil, fmt.Errorf("update request cannot set and clear ended_at at once") + } + + b := &updateSetBuilder{} + setIfPresent(b, "sn_bs_op_id", req.SnBsOpId) + setIfPresent(b, "sn_sc_op_id", req.SnScOpId) + setIfPresent(b, "status", req.Status) + setIfPresent(b, "last_ping_at", req.LastPingAt) + setIfPresent(b, "ended_at", req.EndedAt) + if req.ClearEndedAt { + b.setNull("ended_at") + } + setIfPresent(b, "can_resume", req.CanResume) + setIfPresent(b, "connection_id", req.ConnectionId) + setIfPresent(b, "remote_addr", req.RemoteAddr) + setIfPresent(b, "organization_id", req.OrganizationID) + setIfPresent(b, "encoding", req.Encoding) + setIfPresent(b, "protocol_version", req.ProtocolVersion) + return b, nil +} + // UpdateOperationIDs updates both Base Station and Service Center operation IDs atomically func (r *BaseStationSessionRepository) UpdateOperationIDs(ctx context.Context, tenantID, sessionID int64, bsOpId, scOpId int64) error { query := ` diff --git a/KC-Gateway/internal/proxy/verification_test.go b/KC-Gateway/internal/proxy/verification_test.go index f7e7fb9..87e8539 100644 --- a/KC-Gateway/internal/proxy/verification_test.go +++ b/KC-Gateway/internal/proxy/verification_test.go @@ -228,6 +228,102 @@ func TestVerificationMatrixExternal(t *testing.T) { runVerificationMatrix(t, addr) } +// matrixClient bundles the shared connection and context the matrix assertion +// helpers invoke methods with. +type matrixClient struct { + conn *grpc.ClientConn + ctx context.Context +} + +// mustStatus asserts err carries a gRPC status and returns it. +func mustStatus(t *testing.T, err error) *status.Status { + t.Helper() + st, ok := status.FromError(err) + require.True(t, ok, "expected gRPC status error") + return st +} + +// assertBlocked verifies each fully qualified method is effectively blocked. +// The auth interceptor may reject unauthenticated callers before the service +// routing layer, so Unimplemented and Unauthenticated both count as blocked. +func (c matrixClient) assertBlocked(t *testing.T, label string, methods []string) { + blocked := map[codes.Code]bool{codes.Unimplemented: true, codes.Unauthenticated: true} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + var resp emptypb.Empty + err := c.conn.Invoke(c.ctx, method, &emptypb.Empty{}, &resp) + st := mustStatus(t, err) + assert.True(t, blocked[st.Code()], + "%s method %s should be blocked (Unimplemented or Unauthenticated), got %s: %s", + label, method, st.Code(), st.Message()) + }) + } +} + +// assertNotUnimplemented verifies each fully qualified public method reaches a +// real handler: success or any domain error passes, Unimplemented fails. +func (c matrixClient) assertNotUnimplemented(t *testing.T, methods []string) { + for _, method := range methods { + t.Run(method, func(t *testing.T) { + var resp emptypb.Empty + err := c.conn.Invoke(c.ctx, method, &emptypb.Empty{}, &resp) + if err == nil { + return // OK — public method succeeded + } + st := mustStatus(t, err) + assert.NotEqual(t, codes.Unimplemented, st.Code(), + "public method %s should not return Unimplemented", method) + }) + } +} + +// assertUnauthenticatedUnary verifies each non-public unary method under the +// service prefix rejects anonymous callers with Unauthenticated. +func (c matrixClient) assertUnauthenticatedUnary(t *testing.T, servicePrefix string, methods []string) { + for _, method := range methods { + fullMethod := servicePrefix + method + if grpcconst.PublicMethods[fullMethod] { + continue // Skip public methods + } + t.Run(method, func(t *testing.T) { + var resp emptypb.Empty + err := c.conn.Invoke(c.ctx, fullMethod, &emptypb.Empty{}, &resp) + require.Error(t, err) + st := mustStatus(t, err) + assert.Equal(t, codes.Unauthenticated, st.Code(), + "auth-required method %s should return Unauthenticated, got %s: %s", + fullMethod, st.Code(), st.Message()) + }) + } +} + +// assertUnauthenticatedStream verifies each streaming method under the service +// prefix rejects anonymous callers with Unauthenticated, whether the rejection +// arrives at stream open or on the first receive. +func (c matrixClient) assertUnauthenticatedStream(t *testing.T, servicePrefix string, methods []string) { + for _, method := range methods { + fullMethod := servicePrefix + method + t.Run(method, func(t *testing.T) { + stream, err := c.conn.NewStream(c.ctx, + &grpc.StreamDesc{ServerStreams: true}, + fullMethod) + if err != nil { + st := mustStatus(t, err) + assert.Equal(t, codes.Unauthenticated, st.Code(), + "streaming method %s should return Unauthenticated", fullMethod) + return + } + // Stream opened — try to receive; auth rejection comes here + err = stream.RecvMsg(&emptypb.Empty{}) + require.Error(t, err) + st := mustStatus(t, err) + assert.Equal(t, codes.Unauthenticated, st.Code(), + "streaming method %s should reject with Unauthenticated, got %s", + fullMethod, st.Code()) + }) + } +} + func runVerificationMatrix(t *testing.T, addr string) { conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -237,194 +333,50 @@ func runVerificationMatrix(t *testing.T, addr string) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + client := matrixClient{conn: conn, ctx: ctx} empty := &emptypb.Empty{} - // 1. IdentityInternalService should be blocked (Unimplemented or Unauthenticated) - // Auth interceptor may reject unauthenticated callers before the service - // routing layer, so either code means the method is effectively blocked. t.Run("IdentityInternalService_Blocked", func(t *testing.T) { - blocked := map[codes.Code]bool{codes.Unimplemented: true, codes.Unauthenticated: true} - for _, method := range identityInternalServiceMethods { - t.Run(method, func(t *testing.T) { - var resp emptypb.Empty - err := conn.Invoke(ctx, method, empty, &resp) - st, ok := status.FromError(err) - require.True(t, ok, "expected gRPC status error") - assert.True(t, blocked[st.Code()], - "IdentityInternalService method %s should be blocked (Unimplemented or Unauthenticated), got %s: %s", - method, st.Code(), st.Message()) - }) - } + client.assertBlocked(t, "IdentityInternalService", identityInternalServiceMethods) }) - // 2. Public CoreService RPCs — should NOT be Unimplemented t.Run("Public_CoreService", func(t *testing.T) { - for _, method := range publicCoreServiceMethods { - t.Run(method, func(t *testing.T) { - var resp emptypb.Empty - err := conn.Invoke(ctx, method, empty, &resp) - if err == nil { - return // OK — public method succeeded - } - st, ok := status.FromError(err) - require.True(t, ok) - assert.NotEqual(t, codes.Unimplemented, st.Code(), - "public method %s should not return Unimplemented", method) - }) - } + client.assertNotUnimplemented(t, publicCoreServiceMethods) }) - // 3. Public IdentityService RPCs — should NOT be Unimplemented t.Run("Public_IdentityService", func(t *testing.T) { - for _, method := range publicIdentityServiceMethods { - t.Run(method, func(t *testing.T) { - var resp emptypb.Empty - err := conn.Invoke(ctx, method, empty, &resp) - if err == nil { - return - } - st, ok := status.FromError(err) - require.True(t, ok) - assert.NotEqual(t, codes.Unimplemented, st.Code(), - "public method %s should not return Unimplemented", method) - }) - } + client.assertNotUnimplemented(t, publicIdentityServiceMethods) }) - // 4. Public KiloCenterService compat RPCs — should NOT be Unimplemented t.Run("Public_KiloCenterService", func(t *testing.T) { - for _, method := range publicKiloCenterServiceMethods { - t.Run(method, func(t *testing.T) { - var resp emptypb.Empty - err := conn.Invoke(ctx, method, empty, &resp) - if err == nil { - return - } - st, ok := status.FromError(err) - require.True(t, ok) - assert.NotEqual(t, codes.Unimplemented, st.Code(), - "public method %s should not return Unimplemented", method) - }) - } + client.assertNotUnimplemented(t, publicKiloCenterServiceMethods) }) - // 5. Auth-required CoreService unary RPCs — should return Unauthenticated t.Run("AuthRequired_CoreService_Unary", func(t *testing.T) { - for _, method := range coreServiceUnaryMethods { - fullMethod := "/kilocenter.api.v1.CoreService/" + method - if grpcconst.PublicMethods[fullMethod] { - continue // Skip public methods - } - t.Run(method, func(t *testing.T) { - var resp emptypb.Empty - err := conn.Invoke(ctx, fullMethod, empty, &resp) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code(), - "auth-required method %s should return Unauthenticated, got %s: %s", - fullMethod, st.Code(), st.Message()) - }) - } + client.assertUnauthenticatedUnary(t, "/kilocenter.api.v1.CoreService/", coreServiceUnaryMethods) }) - // 6. Auth-required CoreService streaming RPCs — should reject stream open t.Run("AuthRequired_CoreService_Streaming", func(t *testing.T) { - for _, method := range coreServiceStreamMethods { - fullMethod := "/kilocenter.api.v1.CoreService/" + method - t.Run(method, func(t *testing.T) { - stream, err := conn.NewStream(ctx, - &grpc.StreamDesc{ServerStreams: true}, - fullMethod) - if err != nil { - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code(), - "streaming method %s should return Unauthenticated", fullMethod) - return - } - // Stream opened — try to receive; auth rejection comes here - err = stream.RecvMsg(empty) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code(), - "streaming method %s should reject with Unauthenticated, got %s", - fullMethod, st.Code()) - }) - } + client.assertUnauthenticatedStream(t, "/kilocenter.api.v1.CoreService/", coreServiceStreamMethods) }) - // 7. Auth-required IdentityService RPCs — should return Unauthenticated t.Run("AuthRequired_IdentityService", func(t *testing.T) { - for _, method := range identityServiceUnaryMethods { - fullMethod := "/kilocenter.api.v1.IdentityService/" + method - if grpcconst.PublicMethods[fullMethod] { - continue - } - t.Run(method, func(t *testing.T) { - var resp emptypb.Empty - err := conn.Invoke(ctx, fullMethod, empty, &resp) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code(), - "auth-required method %s should return Unauthenticated, got %s: %s", - fullMethod, st.Code(), st.Message()) - }) - } + client.assertUnauthenticatedUnary(t, "/kilocenter.api.v1.IdentityService/", identityServiceUnaryMethods) }) - // 8. Auth-required KiloCenterService unary RPCs — combined core + identity + // KiloCenterService compat surface combines the core and identity unary sets. t.Run("AuthRequired_KiloCenterService_Unary", func(t *testing.T) { allUnary := make([]string, 0, len(coreServiceUnaryMethods)+len(identityServiceUnaryMethods)) allUnary = append(allUnary, coreServiceUnaryMethods...) allUnary = append(allUnary, identityServiceUnaryMethods...) - for _, method := range allUnary { - fullMethod := "/kilocenter.api.v1.KiloCenterService/" + method - if grpcconst.PublicMethods[fullMethod] { - continue - } - t.Run(method, func(t *testing.T) { - var resp emptypb.Empty - err := conn.Invoke(ctx, fullMethod, empty, &resp) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code(), - "auth-required method %s should return Unauthenticated, got %s: %s", - fullMethod, st.Code(), st.Message()) - }) - } + client.assertUnauthenticatedUnary(t, "/kilocenter.api.v1.KiloCenterService/", allUnary) }) - // 9. Auth-required KiloCenterService streaming RPCs t.Run("AuthRequired_KiloCenterService_Streaming", func(t *testing.T) { - for _, method := range coreServiceStreamMethods { - fullMethod := "/kilocenter.api.v1.KiloCenterService/" + method - t.Run(method, func(t *testing.T) { - stream, err := conn.NewStream(ctx, - &grpc.StreamDesc{ServerStreams: true}, - fullMethod) - if err != nil { - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code(), - "streaming method %s should return Unauthenticated", fullMethod) - return - } - err = stream.RecvMsg(empty) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok) - assert.Equal(t, codes.Unauthenticated, st.Code(), - "streaming method %s should reject with Unauthenticated, got %s", - fullMethod, st.Code()) - }) - } + client.assertUnauthenticatedStream(t, "/kilocenter.api.v1.KiloCenterService/", coreServiceStreamMethods) }) - // 10. Success-path payload checks + // Success-path payload checks t.Run("SuccessPath_CoreService_GetReleaseInfo", func(t *testing.T) { var resp pb.ReleaseInfo err := conn.Invoke(ctx, "/kilocenter.api.v1.CoreService/GetReleaseInfo", empty, &resp) diff --git a/KC-Web/src/modules/users/pages/OrganizationUsers.tsx b/KC-Web/src/modules/users/pages/OrganizationUsers.tsx index d088462..258b3fe 100644 --- a/KC-Web/src/modules/users/pages/OrganizationUsers.tsx +++ b/KC-Web/src/modules/users/pages/OrganizationUsers.tsx @@ -200,68 +200,12 @@ const OrgUsersRemoveDialog: React.FC = ({ ); -const OrganizationUsers: React.FC = ({ - orgId: propOrgId, - embedded, - addDialogOpen: externalAddDialogOpen, - onAddDialogOpenChange, -}) => { - const { id: routeOrgId } = useParams<{ id: string }>(); - const { organizationId: contextOrgId, setOrganization } = - useOrganizationContext(); - const { isHydrated } = useSession(); - const { isServerAdmin, isOrgAdmin } = useCapabilities(); - - // Resolve orgId: prop > route param > context - const orgId = propOrgId || routeOrgId || contextOrgId || ""; - - // Local UI state +/** Search + sort state over the loaded member list. */ +function useOrgUsersFiltering(users: OrganizationUserUI[]) { const [search, setSearch] = useState(""); const [orderBy, setOrderBy] = useState("email"); const [orderDirection, setOrderDirection] = useState("asc"); - // Dialog state - separate dialogs for add (new user) vs edit (existing member) - const [localAddDialogOpen, setLocalAddDialogOpen] = useState(false); - const isAddDialogOpen = externalAddDialogOpen ?? localAddDialogOpen; - const setAddDialogOpen = onAddDialogOpenChange ?? setLocalAddDialogOpen; - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [selectedUser, setSelectedUser] = useState( - null, - ); - - // Remove confirmation state - const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); - const [removeError, setRemoveError] = useState(null); - - // React Query hooks - const canQuery = - isHydrated && (isServerAdmin || isOrgAdmin) && Boolean(orgId); - const { data: org } = useOrganizationQuery(orgId, { - enabled: canQuery && isServerAdmin, - }); - const { - data: usersData, - isLoading, - isError, - error, - } = useOrgUsers(orgId, undefined, { - enabled: canQuery, - }); - - // Remove mutation - const removeOrgUser = useRemoveOrgUser(); - - // Set organization context when org data loads - useEffect(() => { - if (org) { - setOrganization(org.id, org.name); - } - }, [org, setOrganization]); - - // Memoize users array - const users = useMemo(() => usersData?.users ?? [], [usersData?.users]); - - // Filter users by search const filteredUsers = useMemo(() => { const searchLower = search.toLowerCase(); return users.filter((user) => @@ -269,7 +213,6 @@ const OrganizationUsers: React.FC = ({ ); }, [users, search]); - // Sort users const sortedUsers = useMemo( () => sortUsers(filteredUsers, orderBy, orderDirection), [filteredUsers, orderBy, orderDirection], @@ -284,6 +227,37 @@ const OrganizationUsers: React.FC = ({ } }; + return { + search, + setSearch, + orderBy, + orderDirection, + sortedUsers, + handleSort, + }; +} + +/** Add/edit/remove dialog state and handlers, including the remove mutation. */ +function useOrgUsersDialogs( + orgId: string, + externalAddDialogOpen?: boolean, + onAddDialogOpenChange?: (open: boolean) => void, +) { + // Dialog state - separate dialogs for add (new user) vs edit (existing member) + const [localAddDialogOpen, setLocalAddDialogOpen] = useState(false); + const isAddDialogOpen = externalAddDialogOpen ?? localAddDialogOpen; + const setAddDialogOpen = onAddDialogOpenChange ?? setLocalAddDialogOpen; + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState( + null, + ); + + // Remove confirmation state + const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); + const [removeError, setRemoveError] = useState(null); + + const removeOrgUser = useRemoveOrgUser(); + const handleAddClick = () => { setAddDialogOpen(true); }; @@ -301,7 +275,6 @@ const OrganizationUsers: React.FC = ({ setRemoveConfirmOpen(true); }; - // Confirm removal const confirmRemove = () => { if (!selectedUser) return; removeOrgUser.mutate( @@ -319,7 +292,6 @@ const OrganizationUsers: React.FC = ({ ); }; - // Close remove confirmation dialog const handleRemoveDialogClose = () => { setRemoveConfirmOpen(false); setSelectedUser(null); @@ -330,12 +302,121 @@ const OrganizationUsers: React.FC = ({ setAddDialogOpen(false); }; - // Close edit dialog const handleEditDialogClose = () => { setEditDialogOpen(false); setSelectedUser(null); }; + return { + isAddDialogOpen, + editDialogOpen, + selectedUser, + removeConfirmOpen, + removeError, + isRemovePending: removeOrgUser.isPending, + handleAddClick, + handleEdit, + handleRemove, + confirmRemove, + handleRemoveDialogClose, + handleAddDialogClose, + handleEditDialogClose, + }; +} + +interface OrgUsersToolbarProps { + memberCount: number; + search: string; + onSearchChange: (value: string) => void; +} + +/** Member-count stat card and search field shown above the table. */ +const OrgUsersToolbar: React.FC = ({ + memberCount, + search, + onSearchChange, +}) => ( + <> + + + + + + + + + {ORG_USERS_PAGE.TOTAL_MEMBERS} + + {memberCount} + + + + + + + + + onSearchChange(e.target.value)} + /> + + +); + +const OrganizationUsers: React.FC = ({ + orgId: propOrgId, + embedded, + addDialogOpen: externalAddDialogOpen, + onAddDialogOpenChange, +}) => { + const { id: routeOrgId } = useParams<{ id: string }>(); + const { organizationId: contextOrgId, setOrganization } = + useOrganizationContext(); + const { isHydrated } = useSession(); + const { isServerAdmin, isOrgAdmin } = useCapabilities(); + + // Resolve orgId: prop > route param > context + const orgId = propOrgId || routeOrgId || contextOrgId || ""; + + // React Query hooks + const canQuery = + isHydrated && (isServerAdmin || isOrgAdmin) && Boolean(orgId); + const { data: org } = useOrganizationQuery(orgId, { + enabled: canQuery && isServerAdmin, + }); + const { + data: usersData, + isLoading, + isError, + error, + } = useOrgUsers(orgId, undefined, { + enabled: canQuery, + }); + + // Set organization context when org data loads + useEffect(() => { + if (org) { + setOrganization(org.id, org.name); + } + }, [org, setOrganization]); + + const users = useMemo(() => usersData?.users ?? [], [usersData?.users]); + const { + search, + setSearch, + orderBy, + orderDirection, + sortedUsers, + handleSort, + } = useOrgUsersFiltering(users); + const dialogs = useOrgUsersDialogs( + orgId, + externalAddDialogOpen, + onAddDialogOpenChange, + ); + if (!isHydrated) { return null; } @@ -354,38 +435,14 @@ const OrganizationUsers: React.FC = ({ embedded={Boolean(embedded)} orgName={org?.name} orgId={orgId} - onAddClick={handleAddClick} + onAddClick={dialogs.handleAddClick} /> - {/* Statistics Cards */} - - - - - - - - - {ORG_USERS_PAGE.TOTAL_MEMBERS} - - {users.length} - - - - - - - - {/* Search */} - - setSearch(e.target.value)} - /> - + {/* Loading State */} {isLoading && ( @@ -413,8 +470,8 @@ const OrganizationUsers: React.FC = ({ orderBy={orderBy} orderDirection={orderDirection} onSort={handleSort} - onEdit={handleEdit} - onRemove={handleRemove} + onEdit={dialogs.handleEdit} + onRemove={dialogs.handleRemove} emptyMessage={ search ? ORG_USERS_PAGE.NO_MATCH : ORG_USERS_PAGE.NO_USERS } @@ -424,14 +481,14 @@ const OrganizationUsers: React.FC = ({ {/* Add User Dialog - server admin creates new system user; org admin invites by email */} {isServerAdmin ? ( ) : ( @@ -439,20 +496,20 @@ const OrganizationUsers: React.FC = ({ {/* Edit Organization User Dialog - edits role/permissions of existing member */}
);