diff --git a/.github/workflows/pipeline-build.yml b/.github/workflows/pipeline-build.yml index b192929..2804756 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,10 +72,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 (full suite)" + (cd KC-Core && go test -timeout 10m ./...) 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,8 +84,53 @@ jobs: echo "::endgroup::" done + # The KC-DB postgres suite runs against real testcontainers-managed + # 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/...) + 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 @@ -107,7 +151,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 @@ -128,7 +173,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 @@ -149,7 +195,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 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..fd6da94 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(), @@ -144,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/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..9d6979d 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" @@ -15,9 +13,11 @@ 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" + "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 +49,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 +74,21 @@ 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, + 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 @@ -95,16 +98,21 @@ 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, + infra.ConnectionMgr, infra.LoggerIface, infra.TenantID, 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, @@ -118,48 +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.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 } - // 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 { @@ -173,63 +153,134 @@ 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, + 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) } @@ -279,8 +330,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, @@ -414,13 +463,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 { @@ -448,9 +495,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/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.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/config/config.yaml b/KC-Core/config/config.yaml index 1d77b42..9c57bee 100644 --- a/KC-Core/config/config.yaml +++ b/KC-Core/config/config.yaml @@ -31,8 +31,17 @@ 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" + 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/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..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" @@ -42,6 +41,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 { @@ -471,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 @@ -496,6 +504,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 +655,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 +1074,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 +1135,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 +1403,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 +1608,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 +1753,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 +2368,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,14 +2658,17 @@ 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 - } + bsEuiArr := mioty.EUI64(bsEui).ToBytes() + bsEuiBytes := bsEuiArr[:] // Verify tenant ownership first bs, err := s.basestationSvc.GetByEUI(ctx, bsEuiBytes, tenantID) @@ -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,14 +2728,17 @@ 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 - } + bsEuiArr := mioty.EUI64(bsEui).ToBytes() + bsEuiBytes := bsEuiArr[:] // Verify tenant ownership _, err = s.basestationSvc.GetByEUI(ctx, bsEuiBytes, tenantID) @@ -2738,10 +2753,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 +2776,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 +2946,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 +3182,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..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" @@ -50,6 +49,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), @@ -328,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)) @@ -581,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)) @@ -942,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)) @@ -1001,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) @@ -1091,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 } @@ -1106,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_certs.go b/KC-Core/internal/grpc/core_service_certs.go index 7a9e4fa..dcf809d 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" ) @@ -32,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, grpcerrors.LogCertIssuanceRequiresTenant, "bs_eui", req.BsEui, "error", tenantErr) + return nil, status.Error(grpcerrors.GetGRPCCode(grpcerrors.ErrTokenMissingTenantCtx), + grpcerrors.ResolveErrorMessage(grpcerrors.ErrTokenMissingTenantCtx)) + } certReq := &grpcservices.CertificateRequest{ BsEUI: req.BsEui, @@ -44,14 +52,20 @@ 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) - - // 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) + 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 + 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), @@ -61,8 +75,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 @@ -122,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)) } @@ -222,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)) } @@ -292,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_dldata_test.go b/KC-Core/internal/grpc/core_service_dldata_test.go index 271bc0e..2625f37 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" @@ -282,6 +283,13 @@ 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 +299,27 @@ 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) {} + +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 +} + +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 } -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 +348,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_integrations.go b/KC-Core/internal/grpc/core_service_integrations.go index 3844e04..73d10bf 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), @@ -374,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/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 b5e782a..12c5805 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" @@ -246,6 +247,7 @@ func (m *mockDownlinkCmd) SendDLDataQueue( _ bool, _ bool, _ int64, + _ bool, ) error { return nil } @@ -559,15 +561,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 +695,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 +738,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{ @@ -1046,7 +1056,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)}, @@ -2230,6 +2240,13 @@ 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 } @@ -2239,11 +2256,24 @@ 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) {} + +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 +} + +func (s *stubStatusSvc) EvictCachedOperations(_ *bssci.Session) {} 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 } @@ -2613,7 +2643,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 }, @@ -3884,7 +3914,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) @@ -3919,7 +3949,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) @@ -3944,7 +3974,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) @@ -3957,7 +3987,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) @@ -3988,7 +4018,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) @@ -4005,7 +4035,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) @@ -4029,7 +4059,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/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/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/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/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/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/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.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..1da8841 --- /dev/null +++ b/KC-Core/internal/services/bssci/cert_identity_test.go @@ -0,0 +1,131 @@ +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" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" +) + +// 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(testutil.TestContext(), 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(testutil.TestContext(), 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(testutil.TestContext(), 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(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 2760665..0e4c777 100644 --- a/KC-Core/internal/services/bssci/connection_service.go +++ b/KC-Core/internal/services/bssci/connection_service.go @@ -4,28 +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 +// connectionRegistry adapts basestation.ConnectionManager to the narrow +// bssci.BaseStationConnectionRegistry contract the connect flow consumes. +type connectionRegistry struct { + manager *basestation.ConnectionManager + logger logger.Logger } -// NewConnectionService creates a new connection service -func NewConnectionService(log logger.Logger) bssci.ConnectionService { - return &connectionService{ - logger: log, +// 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, } } -// 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) +// 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) } @@ -33,24 +43,9 @@ func (c *connectionService) GetBaseStation(ctx context.Context, eui [8]byte, mgr 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) - if err != nil || bs == nil { - c.logger.Error(bssci.LogBSSCIBaseStationNotFoundInDatabase, - "euiHex", formatEUI(eui), - "error", err) - return nil, bssci.NewCatalogError(bssci.ErrBaseStationNotRegistered, bssci.POSIX_EPERM) - } - - return bs, nil -} - -// 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 +// 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,15 +53,10 @@ func (c *connectionService) RegisterConnection(ctx context.Context, session *bss SessionID: session.ID, } - // 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 { - 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 @@ -75,23 +65,19 @@ 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), - }) +// 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) } -func hexDigit(n byte) byte { - if n < 10 { - return '0' + n +// 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 'A' + (n - 10) + 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 7f0d3c3..8dc5a7c 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,20 +59,16 @@ 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, + ctx context.Context, ownerTenantID int64, ownerOrgUUID uuid.UUID, session *bssci.Session, @@ -72,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 } @@ -80,37 +86,108 @@ func (d *downlinkDispatcher) DispatchIfAvailable( epEUIBytes := make([]byte, 8) binary.BigEndian.PutUint64(epEUIBytes, epEUI) - // Begin transaction via interfaces.Storage - tx, err := d.storage.BeginTx(ownerCtx) + // Short reservation transaction: reserve and commit before any wire write + 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 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) + 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 } - // 2. Build payloads array (UserData takes precedence, fallback to single Payload) + if err := tx.Commit(); err != nil { + d.logger.ErrorContext(ctx, bssci.LogDispatcherTxCommitFailed, "error", err) + return false, nil + } + rollback = false + + return d.dispatchReserved(ctx, 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( + ctx context.Context, + ownerTenantID int64, + ownerOrgUUID uuid.UUID, + session *bssci.Session, + queueID uint64, + epEUI uint64, +) (bool, error) { + if ownerTenantID == 0 { + d.logger.WarnContext(ctx, 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(ctx, ownerTenantID, nil, queueID, epEUIBytes, session.BaseStationEUI) + if err != nil { + d.logger.ErrorContext(ctx, bssci.LogDispatcherQueryFailed, "error", err, "epEui", epEUI, "queId", queueID) + return false, err + } + if dl == nil { + d.logger.WarnContext(ctx, bssci.LogDispatcherNoPending, "epEui", epEUI, "queId", queueID) + return false, nil + } + + return d.dispatchReserved(ctx, 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( + ctx 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,39 +201,46 @@ func (d *downlinkDispatcher) DispatchIfAvailable( dl.DlWindReq, dl.ExpOnly, ownerTenantID, + dl.DlRxStatQry, ) if err != nil { - d.logger.ErrorContext(ownerCtx, bssci.LogDispatcherSendFailed, + if errors.Is(err, bssci.ErrAmbiguousWrite) { + 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(ctx, 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(ctx, + strconv.FormatInt(dl.ID, 10), bssci.DLQueueStatusPending, nil); relErr != nil { + d.logger.ErrorContext(ctx, bssci.LogDispatcherReleaseFailed, + "queId", dl.QueID, "error", relErr) + } + return false, err } - // 4. Mark sent with transmission metadata - // packetCnt = nil (unknown until dlDataQueCmp returns actual value) - // 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( - ownerCtx, + if err := d.storage.MIOTYDownlinks().MarkReservedAsQueued( + ctx, uint64(dl.QueID), //nolint:gosec // G115: QueID is always positive (DB-assigned sequence) 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, + // The send happened: report dispatched, leave the row reserved. The + // dlDataQueRsp handler repeats the idempotent confirmation. + d.logger.ErrorContext(ctx, 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, + d.logger.InfoContext(ctx, bssci.LogDispatcherSuccess, "queId", dl.QueID, "epEui", epEUI, "bsEui", session.BaseStationEUI, diff --git a/KC-Core/internal/services/bssci/downlink_dispatcher_test.go b/KC-Core/internal/services/bssci/downlink_dispatcher_test.go index 9798d03..6f4bf8f 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" @@ -12,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 @@ -39,8 +42,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 +99,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 +135,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 +158,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 +187,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 +221,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,51 +240,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, - }, + reserveResult: dl, + reserveByQueueResult: dl, } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, + tx := &mockTransactionForDispatch{miotyDownlinks: dlRepo} + storageM := &mockStorageForDispatch{tx: tx, dlRepo: dlRepo} + sendFn := &mockSendFn{tx: tx} + return dlRepo, tx, storageM, sendFn +} + +func testDispatchSession() *bssci.Session { + return &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session-123", + BaseStationEUI: 0x1234567890ABCDEF, + }, } - storageM := &mockStorageForDispatch{tx: tx} - sendFn := &mockSendFn{} +} - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) +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, + }) - session := &bssci.Session{ - ID: "test-session-123", - BaseStationEUI: 0x1234567890ABCDEF, - } + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, // ownerTenantID - uuid.New(), // ownerOrgUUID - session, - 0xAABBCCDDEEFF0011, // epEUI - true, // responseExp - false, // dlAck - ) + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -264,37 +306,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") + } + // 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") } } -func TestDispatchIfAvailable_NoPendingDownlinks(t *testing.T) { - dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: nil, // No pending downlinks - } - tx := &mockTransactionForDispatch{ - miotyDownlinks: dlRepo, - } - storageM := &mockStorageForDispatch{tx: tx} - sendFn := &mockSendFn{} +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) - dispatcher := NewDownlinkDispatcher( - &mockLoggerForDispatch{}, - storageM, - sendFn.Send, - ) + dispatched, err := dispatcher.DispatchIfAvailable( + 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) + } + if len(sendFn.dlRxStatQry) != 1 || sendFn.dlRxStatQry[0] != want { + t.Errorf("expected sendFn dlRxStatQry=%v, got %v", want, sendFn.dlRxStatQry) + } + } +} - session := &bssci.Session{ID: "test-session"} +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, - ) + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -305,29 +352,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{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, - ) + testutil.TestContext(), 0, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -341,28 +378,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{ID: "test-session"} + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn.Send) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) // Should gracefully degrade, not return error (don't fail uplink) if err != nil { @@ -373,179 +395,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( + testutil.TestContext(), 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{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) + testutil.TestContext(), 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{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) + testutil.TestContext(), 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") - } -} - -func TestDispatchIfAvailable_CommitError(t *testing.T) { - dlRepo := &mockMIOTYDownlinksForDispatch{ - reserveResult: &storage.DownlinkMessage{ - QueID: 12345, - Payload: []byte("test"), - Priority: 1.0, - }, + // 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) } - 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{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 + testutil.TestContext(), 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{ID: "test-session"} + dispatcher := NewDownlinkDispatcher(&mockLoggerForDispatch{}, storageM, sendFn) dispatched, err := dispatcher.DispatchIfAvailable( - context.Background(), - 42, - uuid.New(), - session, - 0xAABBCCDDEEFF0011, - true, - false, - ) + testutil.TestContext(), 42, uuid.New(), testDispatchSession(), + 0xAABBCCDDEEFF0011, true, false) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -562,3 +535,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( + testutil.TestContext(), 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( + testutil.TestContext(), 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( + testutil.TestContext(), 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/endpoint_attachment_persistence_test.go b/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go new file mode 100644 index 0000000..6fd08f7 --- /dev/null +++ b/KC-Core/internal/services/bssci/endpoint_attachment_persistence_test.go @@ -0,0 +1,161 @@ +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; the wrapped error keeps the cause matchable +// via errors.Is. +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 2a9b8c1..d453f99 100644 --- a/KC-Core/internal/services/bssci/endpoint_attachment_service.go +++ b/KC-Core/internal/services/bssci/endpoint_attachment_service.go @@ -15,7 +15,9 @@ 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" + "github.com/google/uuid" ) // AttachPropagateSender defines the interface for sending attach propagation. @@ -65,10 +67,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 +133,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[:]) @@ -397,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 fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToBeginTransaction, 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 fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToUpdateEndpointAttachMetadata, 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 fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToLoadEndpointSession, 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 fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToUpdateEndpointSession, 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 fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToCreateEndpointSession, err) + } + } + + if err := tx.Commit(); err != nil { + commitErr = err + p.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToCommitAttachTransaction, "error", err) + return fmt.Errorf("%s: %w", bssci.LogBSSCIFailedToCommitAttachTransaction, 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/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") + }) +} diff --git a/KC-Core/internal/services/bssci/factories.go b/KC-Core/internal/services/bssci/factories.go index 4cb9951..99e8341 100644 --- a/KC-Core/internal/services/bssci/factories.go +++ b/KC-Core/internal/services/bssci/factories.go @@ -14,9 +14,10 @@ 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 + ConnectionSvc bssci.BaseStationConnectionRegistry Broadcaster bssci.SCACIBroadcaster // Initially unwired - set via SetSCACIServer EPStatusBroadcaster bssci.SCACIEPStatusBroadcaster // EPStatus adapter - set via SetSCACIEPStatusServer QueueSerializer bssci.QueueSerializer @@ -44,13 +45,20 @@ func NewBSSCIServices( storage interfaces.Storage, systemEventStore interfaces.SystemEventStore, queueStore *postgres.DownlinkQueueReader, + connectionMgr *basestation.ConnectionManager, log logger.Logger, tenantID int64, 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(), @@ -61,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) @@ -88,6 +96,7 @@ func NewBSSCIServices( return &BSSCIServiceBundle{ SessionSvc: sessionSvc, + VersionNegotiator: versionNegotiator, DownlinkSvc: downlinkSvc, StatusSvc: statusSvc, ConnectionSvc: connectionSvc, @@ -96,5 +105,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/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 1e06731..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") @@ -209,11 +211,12 @@ 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() + 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/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/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.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/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 2bb64bb..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,7 @@ package bssciservices import ( - "context" + "errors" "testing" "time" @@ -13,320 +13,288 @@ 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" ) -// 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, + CanResume: true, + Encoding: "msgpack", BaseStationID: 1, - TenantID: 100, + TenantID: tenantID, SnBsUuid: bsUUID, SnScUuid: scUUID, - SnBsOpId: 1000, - SnScOpId: -500, - Status: models.SessionStatusActive, - CanResume: true, - Encoding: "msgpack", - ProtocolVersion: &protocolVersion, // BSSCI §4-4.5: protocol version persisted - OrganizationID: &orgID, + SnBsOpId: bsOpId, + SnScOpId: scOpId, + Status: models.SessionStatusDisconnected, + ProtocolVersion: &protocolVersion, StartedAt: time.Now().Add(-1 * time.Hour), } - mockRepo.sessions[42] = dbSession + repo.sessions[id] = session + return session +} - // 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) +func int64Ptr(v int64) *int64 { return &v } - // Verify: Session restored with all fields - require.NoError(t, err) - require.NotNil(t, restoredSession) +// 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 + + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], + int64Ptr(1000), int64Ptr(-500), 0x123456789ABCDEF0) + + 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) 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() - - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 200, // tenant ID - testLogger, - ).(*sessionService) +// 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) - // 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} + seedResumableSession(mockRepo, 43, 100, bsUUID, [16]byte{0x31}, 2000, -900) - 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), + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, } - mockRepo.sessions[43] = dbSession + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], nil, nil, 0xAABB) + + 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) +} - // 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) +// 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) - // 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) + 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{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], + int64Ptr(400), int64Ptr(-700), 0xCCDD) + + 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") } -// 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_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) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 300, - testLogger, - ).(*sessionService) + 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) - // 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{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], + int64Ptr(1001), int64Ptr(-500), 0xEEFF) - // Verify: Returns nil (no panic, no DB query) - require.NoError(t, err) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) + assert.ErrorIs(t, outcome.Err, bssci.ErrResumeCounterMismatch) } -// 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{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], + int64Ptr(1000), int64Ptr(-501), 0xEEFF) - // Verify: Returns nil cleanly (no panic) - require.NoError(t, err) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeInconsistent, outcome.Disposition) + assert.ErrorIs(t, outcome.Err, bssci.ErrResumeCounterMismatch) } -// 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{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(testutil.TestContext(), testSession, bsUUID[:], nil, nil, 0x1234) - // Verify: Returns nil for fresh session fallback - require.NoError(t, err) - assert.Nil(t, restoredSession) + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition, "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() - - // Create service with tenant 999 (different from session's tenant) - svc := NewSessionService( - mockRepo, - mockBsRepo, - mockEventStore, - 999, // Service tenant ID - testLogger, - ).(*sessionService) +// TestHandleResume_ActiveNotResumableFromDB verifies only disconnected +// sessions qualify for the database resume path. +func TestHandleResume_ActiveNotResumableFromDB(t *testing.T) { + svc, mockRepo := newResumeTestService(t, 100) - 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} + 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 - 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), + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, } - mockRepo.sessions[44] = dbSession + outcome := svc.HandleResume(testutil.TestContext(), 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) + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition, "only disconnected sessions are resumable from persistence") +} - // 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 +// TestHandleResume_ShortUUID_SkipsDBLookup verifies malformed UUIDs never +// reach the database. +func TestHandleResume_ShortUUID_SkipsDBLookup(t *testing.T) { + svc, _ := newResumeTestService(t, 100) + + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(testutil.TestContext(), testSession, []byte{0x01, 0x02}, + int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) + + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) } -// 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{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + outcome := svc.HandleResume(testutil.TestContext(), 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} + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) +} - 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), +// TestHandleResume_NoDBMatch_ReturnNil verifies unknown UUIDs fall back to a +// fresh session. +func TestHandleResume_NoDBMatch_ReturnNil(t *testing.T) { + svc, _ := newResumeTestService(t, 100) + + unknownUUID := [16]byte{0xDE, 0xAD, 0xBE, 0xEF} + testSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, } - mockRepo.sessions[45] = dbSession + outcome := svc.HandleResume(testutil.TestContext(), testSession, unknownUUID[:], + int64Ptr(100), int64Ptr(-50), 0x123456789ABCDEF0) - // 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) + assert.Equal(t, bssci.ResumeNoMatch, outcome.Disposition) +} - // 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") +// 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 - 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) + wrongTenantSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 100, + }, + } + 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") + + correctTenantSession := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ResolvedTenantID: 500, + }, + } + correctOutcome := svc.HandleResume(testutil.TestContext(), correctTenantSession, bsUUID[:], + int64Ptr(5000), int64Ptr(-2500), 0x1122334455667788) + require.Equal(t, bssci.ResumeCompatible, correctOutcome.Disposition) + require.NotNil(t, correctOutcome.Previous) + assert.Equal(t, int64(500), correctOutcome.Previous.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 { @@ -354,16 +322,16 @@ 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{}, SnScUuid: [16]byte{}, SnBsOpId: 4000, SnScOpId: -2000, - Status: models.SessionStatusActive, - CanResume: true, - Encoding: "json", - OrganizationID: tc.orgID, + Status: models.SessionStatusDisconnected, StartedAt: time.Now(), } @@ -378,50 +346,166 @@ 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} 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, SnScUuid: scUUID, SnBsOpId: 3000, SnScOpId: -1500, - Status: models.SessionStatusActive, - CanResume: true, - Encoding: "msgpack", + Status: models.SessionStatusDisconnected, } session := &bssci.Session{ - 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) + err := svc.PersistSession(testutil.TestContext(), 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{ + 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(testutil.TestContext(), sessionB, nil, true, nil)) + require.Equal(t, models.SessionStatusActive, dbSession.Status) + + // Connection A's deferred cleanup runs after B took over + sessionA := &bssci.Session{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "connection-a", + ResolvedTenantID: 900, + DbSessionID: 77, + }, + } + 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, + "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(testutil.TestContext(), sessionB)) + assert.Equal(t, models.SessionStatusDisconnected, dbSession.Status) + 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(testutil.TestContext(), 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(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") +} + +// 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 8388f3c..836fc96 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" @@ -13,13 +14,12 @@ 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" ) 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) @@ -55,267 +55,128 @@ 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 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 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 bssci.ResumeOutcome{Disposition: bssci.ResumeNoMatch} } - // 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) - } + // 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) + binary.BigEndian.PutUint64(euiBytes, bsEUI) - // 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) + dbSession, err := s.bsSessionRepo.FindResumableSession(ctx, s.resolvedTenantID(session), euiBytes, bsUUIDArray) + if err != nil { + // 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), + } } - - // 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) + if dbSession == nil { + return bssci.ResumeOutcome{Disposition: bssci.ResumeNoMatch} } - return nil -} + restoredSession := s.hydrateSessionFromDB(dbSession, bsEUI) -// 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) { - 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 - } - } + if !s.resumeCountersConsistent(ctx, bsEUI, bsOpId, scOpId, dbSession.SnBsOpId, dbSession.SnScOpId) { + return bssci.ResumeOutcome{ + Disposition: bssci.ResumeInconsistent, + Previous: restoredSession, + Err: bssci.ErrResumeCounterMismatch, } } - // 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 - - // 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 + // 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, bssci.LogBSSCIResumeRejectedVersionIncompatible, + "bsEui", bsEUI, + "persistedVersion", restoredSession.NegotiatedVersion, + "selectedVersion", session.NegotiatedVersion) + return bssci.ResumeOutcome{ + Disposition: bssci.ResumeInconsistent, + Previous: restoredSession, + Err: bssci.ErrResumeCounterMismatch, } } - // Try GetSessionByBsUuid fallback (secondary lookup) - if len(bsUUID) == 16 { - var bsUUIDArray [16]byte - copy(bsUUIDArray[:], bsUUID) - - 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)) - } - - // Counters valid - hydrate and return - restoredSession := s.hydrateSessionFromDB(dbSession, bsEUI) + // 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} +} - // 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 +// 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 +} - return nil, nil // No DB match - fallback to fresh session +// 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, bssci.LogBSSCIResumeRejectedBsOpIDBeyondPersisted, + "bsEui", bsEUI, + "requiredBsOpId", *bsOpId, + "persistedBsOpId", knownBsOpId) + return false + } + if scOpId != nil && *scOpId < knownScOpId { + // The BS claims a more negative SC operation ID than the SC issued + s.logger.WarnContext(ctx, bssci.LogBSSCIResumeRejectedScOpIDBeyondIssued, + "bsEui", bsEUI, + "claimedScOpId", *scOpId, + "issuedScOpId", knownScOpId) + return false + } + if scOpId != nil && *scOpId != knownScOpId { + s.logger.WarnContext(ctx, bssci.LogBSSCIResumeAcceptedStaleBsCounter, + "bsEui", bsEUI, + "bsReportedScOpId", *scOpId, + "scAuthoritativeOpId", knownScOpId) + } + return true } // hydrateSessionFromDB creates a bssci.Session from a database record @@ -330,38 +191,37 @@ 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{ - 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) } } // 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 @@ -388,18 +248,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.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, - "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 @@ -481,7 +341,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) @@ -489,7 +349,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)) @@ -502,7 +362,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 @@ -518,7 +378,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, @@ -532,7 +392,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 @@ -547,28 +407,39 @@ 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) 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) } @@ -577,7 +448,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() @@ -587,15 +458,30 @@ 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 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 +// Real cleanup func (s *sessionService) RemoveSession(session *bssci.Session) { s.mu.Lock() defer s.mu.Unlock() @@ -605,15 +491,20 @@ 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 // 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). @@ -623,14 +514,14 @@ 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.Error(bssci.LogBSSCIFailedToUpdateDatabaseSession, + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToUpdateDatabaseSession, "error", err, "sessionID", session.DbSessionID, "bsOpId", session.LastBsOpId, @@ -674,7 +565,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/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/status_service.go b/KC-Core/internal/services/bssci/status_service.go index ee33bf3..67594dc 100644 --- a/KC-Core/internal/services/bssci/status_service.go +++ b/KC-Core/internal/services/bssci/status_service.go @@ -35,33 +35,30 @@ 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{ 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.Error("Failed to marshal pending operation", + s.logger.ErrorContext(ctx, bssci.LogBSSCIFailedToMarshalPendingOperation, "error", err, "opId", opId) 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, bssci.LogBSSCIFailedToMarshalPendingOperationMetadata, "error", err, "opId", opId) return err @@ -69,15 +66,86 @@ 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 +} + +// 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, bssci.LogBSSCIFailedToMarshalPendingOperation, + "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, bssci.LogBSSCIFailedToMarshalPendingOperationMetadata, + "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 @@ -98,9 +166,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 +181,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. @@ -130,6 +201,70 @@ 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.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 { + delete(*s.pendingOps, key) + } + } + s.mu.Unlock() +} + func (s *statusService) ExtractQueueMetadata(session *bssci.Session, opId int64) (endpointEUI uint64, queueID int64, tenantID string) { key := bssci.SessionOpKey{ SessionID: session.ID, @@ -166,27 +301,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 a785c04..2867c61 100644 --- a/KC-Core/internal/services/bssci/status_service_integration_test.go +++ b/KC-Core/internal/services/bssci/status_service_integration_test.go @@ -11,12 +11,15 @@ 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 type mockPendingOperationRepositoryWithCallTracking struct { - createCallCount int - mu sync.Mutex + createCallCount int + deleteBySessionCallCount int + mu sync.Mutex } func (m *mockPendingOperationRepositoryWithCallTracking) Create(_ context.Context, _ *interfaces.PendingOperationRequest) error { @@ -26,6 +29,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 } @@ -39,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 } @@ -64,19 +77,25 @@ 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() + ctx := testutil.TestContext() // Same opId used across all sessions (simulates concurrent downlink operations) opId := int64(-100) @@ -199,11 +218,13 @@ 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() + ctx := testutil.TestContext() // Record a NEW operation (simulates initDLDataQue calling persistPendingOperation) opId := int64(-200) @@ -259,11 +280,13 @@ 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() + ctx := testutil.TestContext() opId := int64(-300) op := &bssci.PendingOperation{ @@ -300,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/internal/services/bssci/test_helpers.go b/KC-Core/internal/services/bssci/test_helpers.go index 3c6f83e..ddd1d6e 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" @@ -123,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 } @@ -231,9 +233,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 +289,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 +345,52 @@ 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 + if m.findErr != nil { + return nil, m.findErr + } + 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 @@ -358,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 } @@ -575,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 @@ -820,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, @@ -845,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) @@ -877,3 +928,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/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..972b44f --- /dev/null +++ b/KC-Core/internal/services/bssci/version_negotiator_test.go @@ -0,0 +1,179 @@ +package bssciservices + +import ( + "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" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" +) + +// 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(testutil.TestContext(), 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(testutil.TestContext(), 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..2c3f96a 100644 --- a/KC-Core/internal/services/certificates/service.go +++ b/KC-Core/internal/services/certificates/service.go @@ -4,11 +4,12 @@ package certificates import ( "bytes" "context" - "crypto/sha256" "crypto/x509" + "encoding/binary" "encoding/hex" "encoding/json" "encoding/pem" + "errors" "fmt" "net" "os" @@ -22,10 +23,48 @@ 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" ) +// 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 { + 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 @@ -36,11 +75,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 @@ -70,7 +120,7 @@ func New(cfg *config.Config, log logger.Logger) *Service { } 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") } @@ -79,7 +129,7 @@ func New(cfg *config.Config, log logger.Logger) *Service { 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 @@ -95,19 +145,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. @@ -117,16 +158,38 @@ 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) - return nil, fmt.Errorf("%s: %s", pkggrpc.ErrTokenInvalidBasestationEUIFormat, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenInvalidBasestationEUIFormat)) + // 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, pkggrpc.NewTokenError(pkggrpc.ErrTokenInvalidBasestationEUIFormat, nil) } + // 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 { 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. + 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 @@ -137,23 +200,15 @@ 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 - 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 @@ -162,22 +217,22 @@ 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 { + 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) } - 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) // 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 { @@ -185,43 +240,42 @@ 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 { + 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) } - 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, - "-dir", certDir, - "-days", fmt.Sprintf("%d", req.ValidityDays), - "-client", req.BsEUI, + // Execute certificate generation with -client-only flag + genArgs := []string{ + certGenFlagDir, certDir, + certGenFlagDays, 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") @@ -241,7 +295,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, @@ -251,16 +305,16 @@ 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 - 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) - } + // 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 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 @@ -275,7 +329,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, @@ -310,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" @@ -323,7 +377,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 @@ -331,13 +385,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 @@ -387,34 +441,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) + return pkggrpc.NewTokenError(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) - } - - // Execute certificate generation command for server certificates + // Execute certificate generation for server certificates serverHostname := s.deriveServerHostname() - cmd := exec.Command(s.certGenPath, - "-dir", kcCorePath, - "-days", fmt.Sprintf("%d", s.serverValidityDays), + genArgs := []string{ + certGenFlagDir, kcCorePath, + certGenFlagDays, 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) @@ -428,44 +475,37 @@ 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) { - return fmt.Errorf("%s", pkggrpc.ErrTokenNoCertsToRenew) + 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) { - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCACertReadFailed, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenCACertReadFailed)) - } - if _, err := os.Stat(filepath.Join(kcCorePath, "ca.key")); os.IsNotExist(err) { - return fmt.Errorf("%s: %s", pkggrpc.ErrTokenCAKeyReadFailed, pkggrpc.ResolveErrorMessage(pkggrpc.ErrTokenCAKeyReadFailed)) + if _, err := os.Stat(filepath.Join(kcCorePath, caCertFileName)); os.IsNotExist(err) { + return pkggrpc.NewTokenError(pkggrpc.ErrTokenCACertReadFailed, nil) } - - // 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) + 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() - cmd := exec.Command(s.certGenPath, - "-dir", kcCorePath, - "-days", fmt.Sprintf("%d", s.serverValidityDays), + genArgs := []string{ + certGenFlagDir, kcCorePath, + certGenFlagDays, 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) @@ -480,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 @@ -490,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 @@ -578,43 +618,22 @@ 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 { - 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) @@ -624,28 +643,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) @@ -659,44 +678,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 + 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) - return fmt.Errorf("%s", pkggrpc.ErrTokenCACertReadFailed) + 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 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 6ceee25..dca6e7d 100644 --- a/KC-Core/internal/services/certificates/service_test.go +++ b/KC-Core/internal/services/certificates/service_test.go @@ -2,15 +2,25 @@ 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" "testing" "time" + "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" @@ -33,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{ @@ -41,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) @@ -73,6 +101,84 @@ 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() + 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-missing"), + certsDir: tmpDir, + tempDir: filepath.Join(tmpDir, "temp"), + serverValidityDays: config.DefaultCertificatesServerValidityDays, + protocolConfig: &config.ProtocolConfig{}, + bsRepo: &mockBaseStationRepo{bs: &models.BaseStation{ID: 1, TenantID: 42}}, + keyEncryptor: &mockKeyEncryptor{}, + certGen: execCertGen, + } +} + +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, + TenantID: 42, + }) + 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, + TenantID: 42, + }) + 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{}, @@ -88,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) } } @@ -110,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) } } @@ -131,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) } } @@ -283,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) @@ -358,10 +467,248 @@ 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", 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() + 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-missing"), + certsDir: tmpDir, + tempDir: filepath.Join(tmpDir, "temp"), + serverValidityDays: config.DefaultCertificatesServerValidityDays, + protocolConfig: &config.ProtocolConfig{}, + bsRepo: repo, + keyEncryptor: &mockKeyEncryptor{}, + certGen: execCertGen, + } +} + +// 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()) + } +} + +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/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/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/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/dl_service.go b/KC-Core/internal/services/scaci/dl_service.go index 89aa898..b54217e 100644 --- a/KC-Core/internal/services/scaci/dl_service.go +++ b/KC-Core/internal/services/scaci/dl_service.go @@ -116,14 +116,14 @@ 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) // 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, @@ -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 4887c17..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" ) // ============================================================================ @@ -22,7 +24,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) } @@ -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..7180530 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 } @@ -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/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..ca4a7a9 100644 --- a/KC-Core/internal/services/scaci/handshake_service.go +++ b/KC-Core/internal/services/scaci/handshake_service.go @@ -144,19 +144,19 @@ 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 } // 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 } @@ -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, scaci.LogSCACICertOrgResolutionFallback, "error", err, "certCN", cert.Subject.CommonName, "defaultTenantID", hs.defaultTenantID) @@ -183,20 +183,20 @@ 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) // 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 } @@ -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, scaci.LogSCACISoftwareVersionNotConfigured, "tenantID", tenantID, "acEui", pkgmioty.FormatEUI64(req.AcEui)) case "dev", "dev-local": - hs.logger.Warn("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) @@ -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/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 209b092..6d54065 100644 --- a/KC-Core/internal/services/scaci/session_persistence.go +++ b/KC-Core/internal/services/scaci/session_persistence.go @@ -32,105 +32,79 @@ func NewSessionPersistence( } } -// 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) - - go func() { - ctx, cancel := context.WithTimeout(context.Background(), scaci.ConnectPersistTimeout) - defer cancel() - - if session.Resumed && session.ID > 0 { - // Update existing session (resumed connection) - now := time.Now() - status := "active" +// connectSnapshot is the immutable copy of every session field the async +// 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 + AcOpIdCounter int64 + ScOpIdCounter int64 + Metadata map[string]interface{} +} - // Prepare TLS evidence pointers for resume per SCACI §1 - var tlsVer, cipherSt *string - if tlsVersion != "" { - tlsVer = &tlsVersion - } - if cipherSuite != "" { - cipherSt = &cipherSuite - } +// 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, + AcOpIdCounter: session.AcOpIdCounter, + ScOpIdCounter: session.ScOpIdCounter, + Metadata: deepCopyMetadata(session.Metadata), + } - 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) - } + if !snap.Resumed || snap.ID <= 0 { + p.logger.ErrorContext(ctx, scaci.LogSCACIPersistSessionFailed, + "error", "PersistResumeAsync requires a resumed session with a persisted ID") + return + } - // Only persist opId counters if they're non-zero (preserve "opId 0 after connect") - if session.AcOpIdCounter > 0 { - updateReq.LastOpIDAc = &session.AcOpIdCounter - } - if session.ScOpIdCounter < 0 { - updateReq.LastOpIDSc = &session.ScOpIdCounter - } + go func() { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), scaci.ConnectPersistTimeout) + defer cancel() - if err := p.sessionRepo.UpdateSession(ctx, session.TenantID, session.ID, updateReq); err != nil { - p.logger.Error(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 - } + now := time.Now() + status := "active" - // Set organization ID if not nil - var orgID *uuid.UUID - if session.OrganizationID != uuid.Nil { - orgID = &session.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: session.TenantID, - OrganizationID: orgID, - AcEUI: session.GetAcEuiBytes(), - SnAcUUID: session.SnAcUUID, - SnScUUID: session.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.Error(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) } }() } @@ -139,12 +113,12 @@ func (p *sessionPersistence) PersistConnectAsync(session *scaci.Session, certFin // 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 d1a9bc5..4ab8fe3 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" ) // ============================================================================ @@ -193,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) @@ -212,12 +213,15 @@ func TestPersistConnectAsync_FreshSession_WritesOrganizationID(t *testing.T) { Resumed: false, // Fresh session } - // Call persistence (async) - svc.PersistConnectAsync(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") @@ -230,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) @@ -247,8 +251,11 @@ func TestPersistConnectAsync_FreshSession_NilOrgID(t *testing.T) { Resumed: false, } - svc.PersistConnectAsync(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() @@ -256,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) @@ -275,7 +281,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.PersistResumeAsync(testutil.TestContext(), session, "TLS 1.3", "TLS_AES_256_GCM_SHA384") mockRepo.waitForCompletion() // Assert UpdateSession was called (not CreateSession) @@ -295,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) @@ -315,9 +320,12 @@ 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", + 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() @@ -340,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) @@ -357,7 +364,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.PersistResumeAsync(testutil.TestContext(), session, newTLSVersion, newCipherSuite) mockRepo.waitForCompletion() require.True(t, mockRepo.wasUpdateCalled()) @@ -372,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 @@ -392,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(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) @@ -429,8 +446,11 @@ func TestPersistConnectAsync_NegotiatedVersionDefault(t *testing.T) { } // Pass empty negotiatedVersion - svc.PersistConnectAsync(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() @@ -448,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 } @@ -460,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() { @@ -483,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) @@ -511,8 +530,11 @@ func TestPersistConnectAsync_FreshSession_WritesNestedMetadata(t *testing.T) { }, } - svc.PersistConnectAsync(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() @@ -536,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) @@ -563,7 +584,7 @@ func TestPersistConnectAsync_ResumedSession_WritesNestedMetadata(t *testing.T) { }, } - svc.PersistConnectAsync(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") @@ -706,7 +727,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 +749,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 +766,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 +787,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 +811,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 +847,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 +872,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/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/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/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/attach_detach_profile_test.go b/KC-Core/pkg/bssci/attach_detach_profile_test.go index 0f1ec45..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), @@ -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{ @@ -517,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), @@ -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..8226f95 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() @@ -497,13 +506,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 +628,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 +811,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) @@ -847,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 601c1ed..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), @@ -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) @@ -210,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), @@ -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) @@ -306,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), @@ -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) @@ -397,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), @@ -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/cert_enforcement_test.go b/KC-Core/pkg/bssci/cert_enforcement_test.go new file mode 100644 index 0000000..b97106b --- /dev/null +++ b/KC-Core/pkg/bssci/cert_enforcement_test.go @@ -0,0 +1,233 @@ +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" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" +) + +// 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(testutil.TestContext(), 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(testutil.TestContext(), 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(testutil.TestContext(), 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(testutil.TestContext(), 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(testutil.TestContext(), 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(testutil.TestContext(), 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(testutil.TestContext(), 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(testutil.TestContext(), 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_failure_test.go b/KC-Core/pkg/bssci/connect_failure_test.go new file mode 100644 index 0000000..464015d --- /dev/null +++ b/KC-Core/pkg/bssci/connect_failure_test.go @@ -0,0 +1,119 @@ +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) GetBaseStationGlobal(_ context.Context, eui [8]byte) (*basestation.BaseStation, error) { + return &basestation.BaseStation{ID: 1, TenantID: 1, EUI: eui, Name: "Test BS"}, nil +} + +func (failingRegistrationConnSvc) RegisterConnection(_ context.Context, _ *Session, _ *basestation.BaseStation) error { + 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 { + 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/connect_handler_test.go b/KC-Core/pkg/bssci/connect_handler_test.go index d82e13b..02b5645 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,12 +48,18 @@ 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 } +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 @@ -206,21 +194,24 @@ 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 } - // 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 +224,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) @@ -283,32 +265,99 @@ func TestConnectCompleteRepairsSessionTenant(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) } - 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{ + 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{}{ + "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 @@ -347,15 +396,25 @@ 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{}{ + "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{}{}) @@ -390,15 +449,18 @@ 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 connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, "bsEui": int64(0x0123456789ABCDEF), "bidi": true, "geoLocation": []interface{}{float64(48.8566), float64(2.3522), float64(35.0)}, @@ -461,15 +523,18 @@ 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) connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, "bsEui": int64(0x0123456789ABCDEF), "bidi": true, "geoLocation": "48.8566,2.3522,35.0", @@ -519,15 +584,18 @@ 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) connectData := map[string]interface{}{ + "version": mioty.MIOTYProtocolVersion, "bsEui": int64(0x0123456789ABCDEF), "bidi": true, "geoLocation": []interface{}{float64(200.0), float64(13.4050), float64(34.0)}, @@ -551,3 +619,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/constants.go b/KC-Core/pkg/bssci/constants.go index 323501e..5702cea 100644 --- a/KC-Core/pkg/bssci/constants.go +++ b/KC-Core/pkg/bssci/constants.go @@ -16,6 +16,45 @@ 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 + + // 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 + + // 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. +// 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 @@ -265,11 +304,11 @@ 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 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 ca0f336..eacd4e9 100644 --- a/KC-Core/pkg/bssci/contracts.go +++ b/KC-Core/pkg/bssci/contracts.go @@ -2,39 +2,84 @@ package bssci 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" ) -// 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 +// 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) +} + +// 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 means no resumable session exists; start a fresh session. + ResumeNoMatch ResumeDisposition = iota + // ResumeCompatible means a resumable session was found and its constraints + // hold; Previous carries the authoritative persisted session state. + ResumeCompatible + // 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 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 +) - // 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) +// 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 +} - // PersistSession writes to basestation_sessions table (server.go:852-950) +// SessionService handles connect/resume with REAL persistence +// Preserves: sessionsByUUID map, DbSessionId, HandshakeComplete, DB persistence +type SessionService interface { + // 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 // 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 @@ -42,8 +87,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. @@ -83,6 +133,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) @@ -95,33 +156,115 @@ type StatusService interface { // 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) + // 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) + + // 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) } -// 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) +// 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 +// 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 + + // 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 +// 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 (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 } @@ -175,9 +318,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 } @@ -672,6 +819,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) @@ -760,3 +922,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/conversions.go b/KC-Core/pkg/bssci/conversions.go index 83ac48f..ee265a0 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,46 @@ 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 + } + } + // Digits already validated above; Atoi rejects overflow of the native int. + v, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return 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..88dfe08 100644 --- a/KC-Core/pkg/bssci/detach_integration_test.go +++ b/KC-Core/pkg/bssci/detach_integration_test.go @@ -6,11 +6,12 @@ import ( "encoding/binary" "encoding/json" "fmt" + "strconv" "sync" "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" @@ -20,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 @@ -180,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 } @@ -205,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), @@ -219,20 +222,23 @@ 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 { 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{ @@ -248,7 +254,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), @@ -533,6 +539,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 } @@ -739,7 +748,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 +764,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} @@ -816,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] @@ -858,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] @@ -886,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 @@ -950,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] @@ -1029,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] @@ -1091,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 571c278..71f8f1f 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 --- @@ -327,6 +326,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() @@ -485,13 +488,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 +613,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 +681,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) @@ -722,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") @@ -835,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, @@ -846,13 +856,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) @@ -884,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/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_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/dl_rx_status_test.go b/KC-Core/pkg/bssci/dl_rx_status_test.go index 658d5f3..22a68c6 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" @@ -25,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 { @@ -152,48 +158,25 @@ 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 logger := logger.NewNop() - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, _ := bssci.CreateTestServices(logger, nil) - server, err := bssci.NewServer(&bssci.Config{}, logger, nil, nil, nil, nil, nil, 1, - sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver, nil, 1) + 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{ + SessionSvc: sessionSvc, + VersionNegotiator: versionNegotiator, + DownlinkSvc: downlinkSvc, + StatusSvc: statusSvc, + ConnectionRegistry: connectionSvc, + QueueSerializer: queueSerializer, + AuditLogger: auditLogger, + TenantResolver: tenantResolver, + TenantID: 1, + DefaultTenantID: 1, + }) require.NoError(t, err, "NewServer should not return error with valid StatusService") server.RegisterHandlers() @@ -202,7 +185,6 @@ func TestHandlerRegistration(t *testing.T) { "dlRxStatRsp", "dlRxStatCmp", "dlRxStatQryRsp", - "dlRxStatQryCmp", } for _, cmd := range expectedHandlers { @@ -210,8 +192,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/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 859e76e..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,13 +67,29 @@ 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), "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.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 + "queId", uint64(queueID), + "bsEui", session.BaseStationEUI, + "error", err) + } + } } // Record success event via audit logger @@ -89,31 +105,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 +167,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() } } @@ -282,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] @@ -318,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{} @@ -339,19 +358,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 @@ -419,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) @@ -485,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{ @@ -526,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 @@ -566,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, @@ -662,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 } @@ -683,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 } @@ -691,10 +743,10 @@ 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.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 } @@ -735,104 +787,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 } @@ -945,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 @@ -972,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 @@ -1082,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) @@ -1103,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) @@ -1128,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 { @@ -1162,41 +1126,37 @@ 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, "Failed to correlate DL RX query", - "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, "Unsolicited DL RX status (no pending query)", - "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) 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) @@ -1297,11 +1257,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{}{ @@ -1314,75 +1276,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, @@ -1391,6 +1312,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.dlrxStore == 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.dlrxStore.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 { @@ -1401,36 +1364,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 } @@ -1443,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) } @@ -1489,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, @@ -1516,20 +1458,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 { @@ -1548,71 +1491,33 @@ 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 - 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 } - - // 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 - } + 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 @@ -1641,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/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..c17ade5 100644 --- a/KC-Core/pkg/bssci/downlink_handlers_test.go +++ b/KC-Core/pkg/bssci/downlink_handlers_test.go @@ -67,13 +67,16 @@ 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, + DbSessionID: 1, + }, + 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..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()) @@ -265,11 +266,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 @@ -291,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) @@ -344,11 +347,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 @@ -373,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) @@ -420,11 +425,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) @@ -437,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 @@ -471,11 +478,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 @@ -504,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 @@ -575,11 +584,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 @@ -604,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 @@ -662,11 +673,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) @@ -679,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/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 ede50c5..f486ab4 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,20 +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" - 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" @@ -199,10 +202,17 @@ 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" + + // 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 @@ -301,7 +311,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 +323,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 +341,13 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.base_station_not_registered", Message: "Base station not registered", SpecSection: "§3.3", - Severity: "error", + 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", @@ -357,55 +373,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 +543,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 +605,29 @@ 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", + 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 +635,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 +697,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 +753,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 +761,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 +769,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 +835,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 +867,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 +887,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 +961,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 +999,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 +1013,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 +1051,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 +1089,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 +1173,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 +1215,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 +1251,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 +1277,37 @@ 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, + }, + errFailedToPersistPendingStatusOperation: { + Token: "bssci.error.failed_to_persist_pending_status_operation", + Message: "Failed to persist pending status operation", + 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", @@ -1275,25 +1333,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 +1363,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 +1421,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 +1493,7 @@ var errorDefinitions = map[string]ErrorDefinition{ Token: "bssci.error.unknown", Message: "Unknown error", SpecSection: "§4", - Severity: "error", + Severity: SeverityError, }, } @@ -1450,7 +1508,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..30bd18b 100644 --- a/KC-Core/pkg/bssci/log_messages.go +++ b/KC-Core/pkg/bssci/log_messages.go @@ -53,6 +53,12 @@ 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" + // 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 @@ -109,6 +115,22 @@ 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" + // 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 + 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 @@ -296,8 +318,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 +326,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 +501,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 +515,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 @@ -553,6 +565,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 @@ -636,6 +650,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 @@ -643,8 +661,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 @@ -679,8 +695,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 @@ -703,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 @@ -880,6 +891,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) @@ -1157,4 +1170,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/mandatory_field_validation_test.go b/KC-Core/pkg/bssci/mandatory_field_validation_test.go index ee7c921..ca6e91a 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, @@ -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{ @@ -494,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() @@ -509,12 +517,15 @@ 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, + DbSessionID: 1, + LastScOpId: -5, + }, + Conn: mockConn, } server.RegisterSession(session) @@ -524,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 @@ -676,11 +695,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 +786,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 +876,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 +1016,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 +1185,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{ @@ -1191,9 +1220,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) }) } @@ -1244,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() @@ -1252,13 +1281,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) @@ -1334,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() @@ -1342,13 +1374,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) @@ -1429,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() @@ -1437,13 +1471,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 f29f54b..833dccc 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" @@ -13,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 @@ -102,20 +121,23 @@ 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) 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, + DbSessionID: 1, + }, + Conn: mockConn, } // Register session with server for operations that look up by sessionID @@ -245,19 +267,22 @@ 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) 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, + DbSessionID: 1, + }, + Conn: mockConn, } // Setup message data @@ -331,21 +356,24 @@ 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) 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, + DbSessionID: 1, + }, + Conn: mockConn, + Bidirectional: true, } server.RegisterSession(session) @@ -379,6 +407,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}, @@ -464,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") @@ -550,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") @@ -585,6 +618,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, false) + 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, false) + 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, false) + 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, false) + 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, false) + 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..b3b9186 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)", @@ -686,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: { @@ -802,6 +792,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 +873,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 +931,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 +1007,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 +1107,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 +1157,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 +1205,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 +1303,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 +1341,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 +1379,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 +1411,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_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..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 @@ -36,169 +32,28 @@ 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)"}, } 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{ - ID: "test-session", - DbSessionID: 1, - Encoding: "json", - HandshakeComplete: true, // Allow non-handshake commands - Conn: &testutil.TestConn{}, // Mock connection to prevent nil pointer crash - } - - // 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{ - 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.go b/KC-Core/pkg/bssci/message_normalizer.go index 12084d3..4026d8b 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,183 +449,187 @@ 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 -func coerceUint64(value interface{}) (uint64, error) { +// 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. +// 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 unsignedToTarget(uint64(v), targetMax, typeName) case uint32: - // uint32 always fits in uint64 - return uint64(v), nil + return unsignedToTarget(uint64(v), targetMax, typeName) case uint16: - // uint16 always fits in uint64 - return uint64(v), nil + return unsignedToTarget(uint64(v), targetMax, typeName) case uint8: - // uint8 always fits in uint64 - 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 v < 0 { - return 0, fmt.Errorf("negative value cannot be coerced to uint64: %f", v) + return floatToTarget(v, maxExactFloat64Integer, targetMax, typeName) + case float32: + 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) } return uint64(v), nil - default: - return 0, fmt.Errorf("cannot convert %T to uint64", value) } + 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 + } + 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 v < 0 || v > 4294967295 { - return 0, fmt.Errorf("value %f out of range for uint32", v) - } - return uint32(v), 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 v < 0 || v > 65535 { - return 0, fmt.Errorf("value %f out of range for uint16", v) - } - return uint16(v), 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 -// 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 +705,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/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 e1f5467..b770410 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. @@ -24,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 { @@ -64,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 } @@ -71,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 } @@ -78,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 } @@ -85,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 } @@ -238,11 +273,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{ @@ -253,7 +290,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} @@ -268,11 +305,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{ @@ -283,7 +322,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} @@ -307,11 +346,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) @@ -324,7 +365,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) @@ -349,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{ @@ -357,11 +398,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{ @@ -373,13 +416,14 @@ 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} _ = 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()) } @@ -395,11 +439,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) @@ -412,7 +458,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) @@ -436,15 +482,17 @@ 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{ - 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{ @@ -456,12 +504,13 @@ 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} _ = server.CallHandleDetachComplete(session, msg, nil) + mock.assertNoPublish(t) assert.Equal(t, 0, mock.detachCount()) } @@ -498,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 @@ -514,12 +562,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: &bsscitest.TestConn{Encoding: "json"}, } // Build UL data map with required fields @@ -565,24 +615,24 @@ 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{ - 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: &bsscitest.TestConn{Encoding: "json"}, } data := map[string]interface{}{ @@ -597,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()) } @@ -629,12 +680,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: &bsscitest.TestConn{Encoding: "json"}, } // Build DL result data map (handleDLDataResult uses type assertions directly) @@ -681,12 +734,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: &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/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 317e6af..6b7f79b 100644 --- a/KC-Core/pkg/bssci/outbound_validation_test.go +++ b/KC-Core/pkg/bssci/outbound_validation_test.go @@ -128,12 +128,13 @@ func TestValidateAllCatalogCommands(t *testing.T) { config: &Config{}, logger: logger, } - 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 { @@ -155,12 +156,13 @@ func TestValidationMissingCommand(t *testing.T) { config: &Config{}, logger: logger, } - 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 @@ -188,12 +190,13 @@ func TestValidationMissingOpId(t *testing.T) { config: &Config{}, logger: logger, } - 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) @@ -272,12 +275,13 @@ func TestValidationExtraField(t *testing.T) { config: &Config{}, logger: logger, } - 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 @@ -306,12 +310,13 @@ func TestValidationUnknownCommand(t *testing.T) { config: &Config{}, logger: logger, } - 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 @@ -340,12 +345,7 @@ func TestValidationMarshalFailure(t *testing.T) { logger: logger, } - 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 +360,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") } @@ -380,11 +380,12 @@ func TestValidationStructMessages(t *testing.T) { logger: logger, } - 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) { @@ -407,7 +408,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 +432,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/pending_op_leak_test.go b/KC-Core/pkg/bssci/pending_op_leak_test.go new file mode 100644 index 0000000..4a95700 --- /dev/null +++ b/KC-Core/pkg/bssci/pending_op_leak_test.go @@ -0,0 +1,130 @@ +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.SetStorageForTest(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 + 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", + 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: "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, + 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) + 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: metadata, + } + 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 the others) + "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..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 @@ -25,15 +26,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 @@ -59,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) @@ -99,9 +104,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 @@ -121,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) @@ -174,8 +179,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 +197,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,8 +249,13 @@ func TestPendingOperationSessionSlug(t *testing.T) { sessionSvc, downlinkSvc, statusSvc, connectionSvc, broadcaster, queueSerializer, auditLogger, tenantResolver) - session := &Session{ID: sessionID, DbSessionID: 1} - ctx := context.Background() + session := &Session{ + ProtocolSessionState: ProtocolSessionState{ + ID: sessionID, + DbSessionID: 1, + }, + } + ctx := testutil.TestContext() err := server.statusSvc.RecordPendingOperation(ctx, session, opID, op, session.DbSessionID) require.NoError(t, err, "Should record pending operation") @@ -269,11 +283,11 @@ 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() + 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 c6c0149..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, @@ -325,8 +321,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 +348,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 +372,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 +393,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 +434,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, }, }, } @@ -472,12 +478,13 @@ func Test_validateOutboundMessage_CatalogTokens(t *testing.T) { config: &Config{}, logger: logger, } - 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 @@ -490,7 +497,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") } @@ -504,7 +511,6 @@ func Test_validateOutboundMessage_CatalogTokens(t *testing.T) { config: &Config{}, logger: logger2, } - server2.broadcastFn = server2.SendAttachPropagateToAll validMsg := &Message{ Command: mioty.CmdConnectResponse, @@ -519,7 +525,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..6e5bc79 100644 --- a/KC-Core/pkg/bssci/protocol_edge_cases_test.go +++ b/KC-Core/pkg/bssci/protocol_edge_cases_test.go @@ -1,20 +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 @@ -88,11 +90,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" @@ -125,9 +129,83 @@ 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{ + ProtocolSessionState: bssci.ProtocolSessionState{ + ID: "test-session-negotiate-down", + Encoding: encoding, + }, + Conn: mockConn, + } + + 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() @@ -153,12 +231,15 @@ func TestBSSCI_2_4_02_optional_fields_default(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 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 +251,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") }) } } @@ -237,11 +321,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{ @@ -288,11 +374,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 @@ -354,11 +442,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 @@ -394,11 +484,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 @@ -431,11 +523,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 @@ -478,11 +572,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 @@ -519,17 +615,18 @@ 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, 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 +635,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"} @@ -545,11 +643,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 @@ -583,14 +684,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, @@ -617,27 +720,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{ @@ -647,39 +751,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)") - - // 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 snBsOpId without snScOpId") - 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{ @@ -689,16 +789,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") }) } @@ -719,7 +814,7 @@ func TestConConditionalRules(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 @@ -818,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() @@ -833,11 +928,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{ @@ -859,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)) @@ -868,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/resume_reissue_test.go b/KC-Core/pkg/bssci/resume_reissue_test.go new file mode 100644 index 0000000..6771b4a --- /dev/null +++ b/KC-Core/pkg/bssci/resume_reissue_test.go @@ -0,0 +1,297 @@ +package bssci + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "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 + closes int + buf bytes.Buffer +} + +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 + } + c.buf.Write(b) + return len(b), 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 } +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: 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} + session := newResumeSession(conn, []*PendingOperation{ + statusReissueOp(-1), + statusReissueOp(-2), + }) + t.Cleanup(func() { stopSessionStatus(session) }) + + msg := &Message{OpId: 0, Command: mioty.CmdConnectComplete} + 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") +} + +// 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%%%"]}` + } + return PersistedOperation{ + OperationID: opID, + OperationType: opType, + OperationData: []byte(fmt.Sprintf(`{"command":%q,"opId":%d}`, opType, opID)), + Metadata: []byte(metadata), + } +} + +// 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) + + // 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 +// 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/runtime_wiring_test.go b/KC-Core/pkg/bssci/runtime_wiring_test.go new file mode 100644 index 0000000..eea0810 --- /dev/null +++ b/KC-Core/pkg/bssci/runtime_wiring_test.go @@ -0,0 +1,108 @@ +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. 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() + server.started = true + server.mu.Unlock() + + err := server.ConfigureRuntime(newRuntimeDeps()) + require.Error(t, err) + 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 +// 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/bssci/server.go b/KC-Core/pkg/bssci/server.go index 0875aaf..0b5ae2d 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,30 +109,35 @@ 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 - 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 + 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 - defaultTenantID int64 // Community mode fallback tenant ID - - // Keep essential infrastructure - deduplicator *MessageDeduplicator - eventStore interfaces.SystemEventStore - basestationRepo interfaces.BaseStationRepository - endpointRepo interfaces.EndpointRepository - keyEncryptor *crypto.KeyEncryptor + 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 + certIdentityResolver CertificateIdentityResolver + bsDirectory RegisteredBaseStationDirectory + defaultTenantID int64 // Community mode fallback tenant ID + + // 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 @@ -147,6 +151,21 @@ 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 + + // 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 @@ -166,14 +185,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 @@ -199,34 +210,64 @@ 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 (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 + 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 } -// Session represents a connected Base Station session +// 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 +) + +// 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 @@ -241,20 +282,111 @@ 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 - mu sync.Mutex + 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 +} + +// 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. +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 + // 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 + // 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 + // 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. // 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 +418,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 +462,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 +660,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 { @@ -632,72 +731,119 @@ func (s *Server) validateOutboundMessage(session *Session, message interface{}) 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, - downlinkSvc DownlinkService, - statusSvc StatusService, - connectionSvc ConnectionService, - 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 + 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.Warn(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(5 * time.Minute), // 5 minute dedup window per MIOTY spec - storage: stor, - tenantID: tenantID, - eventStore: eventStore, - basestationRepo: basestationRepo, - endpointRepo: endpointRepo, - keyEncryptor: keyEncryptor, - // Injected services - sessionSvc: sessionSvc, - downlinkSvc: downlinkSvc, - statusSvc: statusSvc, - connectionSvc: connectionSvc, - 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 + 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, + + 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 s.registerHandlers() @@ -705,91 +851,150 @@ 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 +// 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 { + 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") + } + 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 } -// 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 +// 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 } -// 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 +// 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 } -// 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 -} +// 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) + } -// 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 -} + presented := crypto.CertFingerprintSHA256(session.ClientCert.Raw) -// 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 -} + 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 + } -// 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 + 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 } // formatTenantID formats the server's tenant ID as a string for database operations @@ -812,7 +1017,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 +1025,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 +1040,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 @@ -874,9 +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() + 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") + } + if err := s.validateRuntimeWiring(); err != nil { + s.mu.Unlock() + return fmt.Errorf("server wiring incomplete: %w", err) + } + 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, @@ -894,8 +1122,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 { @@ -912,6 +1139,7 @@ func (s *Server) waitForCertsAndStart() { s.logger.ErrorContext(s.safeCtx(), LogBSSCIDeferredListenerFailed, "error", err) continue } + s.startDLRXQueryExpiryWorker() return } } @@ -969,15 +1197,20 @@ 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 { s.logger.ErrorContext(s.safeCtx(), LogBSSCIFailedToCloseListener, "error", err) } } - if s.deduplicator != nil { - s.deduplicator.Stop() - } s.wg.Wait() return nil } @@ -1015,12 +1248,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 @@ -1042,26 +1277,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 @@ -1070,8 +1330,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 } @@ -1105,6 +1365,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 +1376,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() + + // 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.connectionRegistry != nil { + euiBytes := mioty.EUI64(session.BaseStationEUI).ToBytes() - ctx := s.sessionContext(session) - if err := s.connectionMgr.UpdateConnectionStatus(ctx, euiBytes, status); err != nil { + if err := s.connectionRegistry.DisconnectBaseStationIfCurrent(ctx, euiBytes, session.ID); err != nil { s.logger.ErrorContext(ctx, LogBSSCIFailedToUpdateOfflineStatus, "eui", session.BaseStationEUI, "error", err) @@ -1138,46 +1404,50 @@ 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) - } + // 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. 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) } }() @@ -1189,8 +1459,21 @@ 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: 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) + 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) return } @@ -1231,7 +1514,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, @@ -1289,8 +1572,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 @@ -1301,6 +1587,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) } @@ -1312,16 +1609,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 { - var euiBytes [8]byte - for i := 7; i >= 0; i-- { - euiBytes[i] = byte(session.BaseStationEUI >> (8 * (7 - i))) - } + 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) @@ -1344,6 +1638,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 @@ -1351,17 +1654,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.) @@ -1386,6 +1680,13 @@ 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) } @@ -1398,9 +1699,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, @@ -1409,14 +1713,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) } } @@ -1433,6 +1735,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) @@ -1453,26 +1774,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) @@ -1491,70 +1813,246 @@ 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") + +// 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 +// 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 { + if s.config != nil && s.config.OperationAckTimeout > 0 { + return s.config.OperationAckTimeout + } + 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 +} + +// 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 { + 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 +} + +// 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.dlrxStore == 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: + 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.dlrxStore.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 +// 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,93 +2071,195 @@ 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.connectionRegistry.GetBaseStationGlobal(ctx, euiBytes) + 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) + } + + // 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 + 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} + } } } // BSSCI-3.3: Session resume handling var snResume bool 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 - } - - // Delegate resume validation to SessionService - previousSession, err = s.sessionSvc.HandleResume(session, bsUUID, scUUIDToMatch, bsOpId, scOpId, session.BaseStationEUI) - if err != nil { - // Log error but continue with new session - s.logger.WarnContext(s.safeCtx(), LogBSSCIFailedToCheckSessionResume, - "error", err, + scOpId = &scOp + } + + // 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) + 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 } 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 + // 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) + } + + // 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 + // 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) @@ -1677,13 +2277,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 +2325,83 @@ 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 +} + +// 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.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 +} + +// 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 +// 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,63 +2409,56 @@ 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) + // 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. - // Delegate database session persistence to SessionService + // 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, "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 + // 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.connectionRegistry.RegisterConnection(ctx, session, baseStation); 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) } - // Update the base station's bidi capability and GPS location in the database + // 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 (non-critical enrichment; failure is logged, not fatal) if s.basestationRepo != nil { updates := map[string]interface{}{ "bidi": session.Bidirectional, @@ -1830,99 +2483,74 @@ 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 - // Start status mechanism for this session - s.startStatusMechanism(session) - s.logger.InfoContext(ctx, LogBSSCIStartedStatusMechanismForBaseStation, - "bsEui", session.BaseStationEUI) + // 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) + } + } - // 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 - } + // 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) + } - // 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 - } - } + 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) + // 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: 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) + 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 { - 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 @@ -2013,11 +2641,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, @@ -2029,24 +2659,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 } @@ -2076,11 +2695,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, @@ -2092,24 +2713,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 } @@ -2119,8 +2729,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 +2855,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 +2898,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 +3021,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 +3031,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, @@ -2541,104 +3147,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 @@ -2695,8 +3231,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 +3299,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 +3326,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 +3365,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 +3390,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 +3423,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 +3432,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) } } @@ -2975,8 +3507,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, @@ -3031,11 +3566,11 @@ 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", epEuiUint, + "epEui", epEUI, "bsEui", session.BaseStationEUI, "opId", msg.OpId) } @@ -3047,7 +3582,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 +3603,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) } } @@ -3165,7 +3700,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() } } @@ -3236,7 +3771,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 +3779,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 +3920,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 +4026,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 +4038,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 +4154,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 +4183,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 +4296,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,101 +4383,53 @@ func (s *Server) handleError(_ *Server, session *Session, msg *Message, data map "message", message, "baseStation", session.BaseStationEUI) - // 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)) - } - 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" - } + // 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, } - - 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 + 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) + } - // Send completion message - completionMsg := map[string]interface{}{ - "command": completionCommand, - "opId": 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 + } - 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 @@ -3962,9 +4442,34 @@ func (s *Server) handleErrorAck(_ *Server, session *Session, msg *Message, _ map "opId", msg.OpId, "baseStationEUI", session.BaseStationEUI) - // 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 { + // 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) + } + + // 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, @@ -3973,8 +4478,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 } @@ -4060,12 +4570,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,97 +4585,114 @@ 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 +} + +// 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 } - return defaultValue + 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 } -func getNumericField(data map[string]interface{}, key string) (int64, bool) { +// 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 } - - 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: + 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 +4709,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 +4748,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: @@ -4330,25 +4813,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 } @@ -4372,7 +4857,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 } @@ -4382,14 +4867,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) @@ -4400,14 +4885,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 } @@ -4423,32 +4907,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 +5027,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 +5223,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: @@ -4984,10 +5380,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 @@ -5045,9 +5444,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 @@ -5088,76 +5489,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) - } - 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) - } + // 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) } - - 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, @@ -5172,28 +5518,24 @@ 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 } // 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) @@ -5238,8 +5580,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, @@ -5250,17 +5592,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 } @@ -5703,7 +6034,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 { @@ -5764,7 +6095,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, @@ -5796,11 +6127,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, @@ -5828,9 +6161,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 @@ -5898,8 +6233,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, @@ -5909,37 +6244,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 } @@ -6193,7 +6513,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) @@ -6212,7 +6532,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, @@ -6234,17 +6554,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 { @@ -6273,8 +6637,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, @@ -6285,22 +6648,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 } @@ -6318,25 +6689,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, @@ -6353,16 +6711,35 @@ 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 - if sessionID == 0 { + 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, @@ -6379,24 +6756,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{}) } @@ -6819,7 +7203,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 @@ -6837,34 +7243,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 +7325,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,21 +7350,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 { + if session.DbSessionID == 0 || s.sessionSvc == 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( - s.safeCtx(), - sessionUUID, - 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/session_lookup_test.go b/KC-Core/pkg/bssci/session_lookup_test.go index 367ff02..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 { @@ -38,14 +39,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 +78,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 +103,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 +145,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 +161,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 +201,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 +254,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 +311,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 +365,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..2142835 100644 --- a/KC-Core/pkg/bssci/session_opid_rollback_test.go +++ b/KC-Core/pkg/bssci/session_opid_rollback_test.go @@ -1,312 +1,99 @@ 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}, }, } 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) - 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{ - 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{ - 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() - } - - // 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{ - 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") -} +// 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 + ) -// TestRollbackWithConcurrency verifies that rollback logic is thread-safe -// under concurrent access scenarios. -func TestRollbackWithConcurrency(t *testing.T) { session := &Session{ - BaseStationEUI: TestEpEui01, - LastScOpId: 0, + ProtocolSessionState: ProtocolSessionState{ + BaseStationEUI: TestEpEui01, + }, } - 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{ - 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/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..05bf552 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" @@ -10,13 +11,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 { @@ -221,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{ @@ -242,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) } } @@ -250,27 +244,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 } @@ -292,10 +283,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, @@ -328,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, @@ -343,24 +339,26 @@ 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 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) - // Continue anyway - persistence failure shouldn't block operation + 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) @@ -368,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/status_handlers_test.go b/KC-Core/pkg/bssci/status_handlers_test.go index 9a9ec7b..045849d 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{ @@ -2380,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/bssci/sublayer_test.go b/KC-Core/pkg/bssci/sublayer_test.go index 78828da..c1d313d 100644 --- a/KC-Core/pkg/bssci/sublayer_test.go +++ b/KC-Core/pkg/bssci/sublayer_test.go @@ -56,11 +56,14 @@ 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, + DbSessionID: 1, + }, + 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 5e50ad0..c37e786 100644 --- a/KC-Core/pkg/bssci/tenant_context_test.go +++ b/KC-Core/pkg/bssci/tenant_context_test.go @@ -11,17 +11,15 @@ 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 type stubSessionService struct{} -func (s *stubSessionService) ValidateVersion(_ string) error { - return nil // No-op for smoke tests -} - -func (s *stubSessionService) HandleResume(_ *Session, _ []byte, _ []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 { @@ -40,11 +38,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 } @@ -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") @@ -375,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/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_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 2fafc1b..b225c7e 100644 --- a/KC-Core/pkg/bssci/test_helpers_test.go +++ b/KC-Core/pkg/bssci/test_helpers_test.go @@ -9,14 +9,41 @@ 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. // 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, @@ -25,8 +52,8 @@ func NewTestServer( sessionSvc SessionService, downlinkSvc DownlinkService, statusSvc StatusService, - connectionSvc ConnectionService, - broadcaster SCACIBroadcaster, + connectionSvc BaseStationConnectionRegistry, + _ SCACIBroadcaster, queueSerializer QueueSerializer, auditLogger AuditLogger, tenantResolver TenantResolver, @@ -37,27 +64,24 @@ 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 - } - // Wire endpointRepo if storage is available (prevents nil panics in resolveEndpointTenantID) - if storage != nil { - s.endpointRepo = storage.EndPoints() + logger: log, + eventStore: eventStore, + tenantID: tenantID, + sessionSvc: sessionSvc, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionRegistry: connectionSvc, + 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 } + // 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) - s.broadcastFn = s.SendAttachPropagateToAll return s } @@ -71,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() @@ -131,11 +150,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) @@ -153,11 +208,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) @@ -236,9 +286,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. @@ -251,8 +302,8 @@ func NewTestServerWithBaseStationRepo( sessionSvc SessionService, downlinkSvc DownlinkService, statusSvc StatusService, - connectionSvc ConnectionService, - broadcaster SCACIBroadcaster, + connectionSvc BaseStationConnectionRegistry, + _ SCACIBroadcaster, queueSerializer QueueSerializer, auditLogger AuditLogger, tenantResolver TenantResolver, @@ -263,22 +314,21 @@ 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, + basestationRepo: basestationRepo, + tenantID: tenantID, + sessionSvc: sessionSvc, + downlinkSvc: downlinkSvc, + statusSvc: statusSvc, + connectionRegistry: connectionSvc, + queueSerializer: queueSerializer, + auditLogger: auditLogger, + tenantResolver: tenantResolver, + versionNegotiator: staticTestVersionNegotiator{}, + sessions: make(map[string]*Session), } - // Initialize broadcast hook (tests can override) - s.broadcastFn = s.SendAttachPropagateToAll + s.SetStorageForTest(storage) + s.basestationRepo = basestationRepo return s } @@ -342,27 +392,25 @@ 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) ResumeOutcome { 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 is an inconsistent resume + if bsOpId != nil && *bsOpId > existingSession.LastBsOpId { + return ResumeOutcome{Disposition: ResumeInconsistent, Previous: existingSession, Err: ErrResumeCounterMismatch} } + if scOpId != nil && *scOpId < existingSession.LastScOpId { + return ResumeOutcome{Disposition: ResumeInconsistent, Previous: existingSession, Err: ErrResumeCounterMismatch} + } + 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 { @@ -425,12 +473,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() @@ -478,16 +533,72 @@ 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 + // 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 + // persistedRows backs PersistedOperations for resume tests that inject + // raw rows (including malformed ones) into the load path. + persistedRows []PersistedOperation +} + +// 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 { 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() @@ -502,6 +613,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 @@ -521,15 +633,53 @@ func (m *memoryStatusService) ExtractQueueMetadata(session *Session, opId int64) return } -func (m *memoryStatusService) ProcessOperationStatusUpdate(_ context.Context, _ *Session, _ int64, _ string) error { +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) CleanupPendingOp(session *Session, opId int64) { +func (m *memoryStatusService) PersistedOperations(_ context.Context, _ int64) ([]PersistedOperation, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return append([]PersistedOperation(nil), m.persistedRows...), nil +} + +func (m *memoryStatusService) DeletePendingOperations(_ context.Context, session *Session) (int64, error) { m.mu.Lock() defer m.mu.Unlock() - key := makeSessionOpKey(session, opId) - delete(*m.pendingOps, key) + m.deleteSessionCalls++ + var count int64 + for key := range *m.pendingOps { + if key.SessionID == session.ID { + delete(*m.pendingOps, key) + count++ + } + } + 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 } // Canonical test mocks defined in test_mocks_test.go (same package, test-only compilation). @@ -543,7 +693,7 @@ func CreateTestServices(log logger.Logger, _ interfaces.SystemEventStore) ( SessionService, DownlinkService, StatusService, - ConnectionService, + BaseStationConnectionRegistry, SCACIBroadcaster, QueueSerializer, AuditLogger, @@ -572,3 +722,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/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/ul_transmit.go b/KC-Core/pkg/bssci/ul_transmit.go index a62907b..a84fc82 100644 --- a/KC-Core/pkg/bssci/ul_transmit.go +++ b/KC-Core/pkg/bssci/ul_transmit.go @@ -72,111 +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) } - // 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)) + return nil, fmt.Errorf("%s, got %d", ResolveErrorMessage(errNwkSnKeyInvalidLength), len(nwkSnKey)) } - // 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 + return session, nil +} + +// 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, @@ -184,49 +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 +} - // Persist operation for session resume (with encrypted key in metadata) - 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) - // Continue anyway - operation can still proceed + "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 (rollback guard for 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() - 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 } // handleULDataTxResponse handles the base station's response to ulDataTx @@ -397,18 +436,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/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/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/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 new file mode 100644 index 0000000..c8ecfed --- /dev/null +++ b/KC-Core/pkg/bssci/version_interop_test.go @@ -0,0 +1,540 @@ +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" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" +) + +// 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) 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) error { + 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 { + t *testing.T + conn net.Conn + encoding string + server *Server + done chan 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) + + 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, + } + if mutate != nil { + mutate(server.config) + } + server.RegisterHandlers() + ctx, cancel := context.WithCancel(testutil.TestContext()) + 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)) +} + +// 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") + + // 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) { + // 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) + }) + } +} + +// 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/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..fdcc88e 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 @@ -18,10 +19,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, @@ -45,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") @@ -75,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") @@ -105,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") @@ -136,12 +139,14 @@ 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{ - 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 +235,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 +274,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, @@ -287,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") @@ -315,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") @@ -341,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") @@ -377,7 +386,6 @@ func createTestServerWithSession() *Server { broadcaster, queueSerializer, auditLogger, tenantResolver, ) - s.deduplicator = NewMessageDeduplicator(1000) return s } 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 3404c89..12e4071 100644 --- a/KC-Core/pkg/config/constants.go +++ b/KC-Core/pkg/config/constants.go @@ -368,15 +368,45 @@ 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 // DefaultProtocolAckTimeout is the acknowledgement timeout in milliseconds. DefaultProtocolAckTimeout = 5000 + // 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 + // 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..d8671de 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) { @@ -111,7 +166,13 @@ 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.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) // 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..fdf39a8 100644 --- a/KC-Core/pkg/config/protocol.go +++ b/KC-Core/pkg/config/protocol.go @@ -26,10 +26,18 @@ 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 + 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"` // Automatic Propagation Reconciliation Propagation PropagationConfig `mapstructure:"propagation"` @@ -42,6 +50,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..7327207 100644 --- a/KC-Core/pkg/config/service_center.go +++ b/KC-Core/pkg/config/service_center.go @@ -28,6 +28,38 @@ 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.ConnectionEstablishmentTimeout <= 0 { + 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) + } + + 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..345ce49 100644 --- a/KC-Core/pkg/config/service_center_test.go +++ b/KC-Core/pkg/config/service_center_test.go @@ -336,9 +336,17 @@ 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, + ConnectionEstablishmentTimeout: DefaultProtocolConnectionEstablishmentTimeout, + StatusRequestInterval: DefaultProtocolStatusRequestInterval, + StatusRequestInitialDelay: DefaultProtocolStatusRequestInitialDelay, + DLRXQueryTimeout: DefaultProtocolDLRXQueryTimeout, + DLRXCleanupInterval: DefaultProtocolDLRXCleanupInterval, + DuplicateWindow: DefaultProtocolDuplicateWindow, + BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, } err := ValidateServiceCenterConfig(cfg) @@ -347,6 +355,66 @@ 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, + ConnectionEstablishmentTimeout: DefaultProtocolConnectionEstablishmentTimeout, + StatusRequestInterval: DefaultProtocolStatusRequestInterval, + StatusRequestInitialDelay: DefaultProtocolStatusRequestInitialDelay, + DLRXQueryTimeout: DefaultProtocolDLRXQueryTimeout, + DLRXCleanupInterval: DefaultProtocolDLRXCleanupInterval, + DuplicateWindow: DefaultProtocolDuplicateWindow, + BSCICertificatePollInterval: DefaultProtocolCertificatePollInterval, + } + } + + zeroAck := base() + zeroAck.AckTimeout = 0 + if err := ValidateServiceCenterConfig(zeroAck); err == nil { + 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") + } + + 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 { + 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/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/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/grpc/errors_catalog.go b/KC-Core/pkg/grpc/errors_catalog.go index e9bd43f..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" ) @@ -125,6 +127,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" @@ -168,6 +171,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" @@ -361,11 +373,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" @@ -865,6 +879,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", @@ -1051,6 +1070,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: { @@ -1818,6 +1872,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", @@ -2188,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-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/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/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.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/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/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/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-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-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/contracts.go b/KC-Core/pkg/scaci/contracts.go index 830af97..314a48a 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,39 +699,32 @@ type ErrorRecorder interface { // // Example Usage (handler_connect.go): // -// s.sessionPersistence.PersistConnectAsync(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(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) @@ -791,3 +785,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/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.go b/KC-Core/pkg/scaci/handler_connect.go index 5bcda6f..3ca6b3e 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 @@ -257,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) @@ -304,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(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/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_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 0ddc517..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 { @@ -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..381ba72 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" @@ -22,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. @@ -962,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) @@ -1014,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) @@ -1032,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) @@ -1784,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, }) @@ -1896,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 @@ -2018,9 +2019,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 +2046,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(), @@ -2064,19 +2064,22 @@ func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSuccess(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) 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 +2102,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(), @@ -2119,11 +2120,12 @@ func TestProcessDLDataQueueCore_StatusUpdateUsesQueueIDOnSchedulerFailure(t *tes 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) + mockDL.AssertNotCalled(t, "UpdateDownlinkStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) mockDL.AssertExpectations(t) mockEndpoint.AssertExpectations(t) } 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/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..316b69f 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) } @@ -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(session *Session, certFingerprint, certSubject, remoteAddr, tlsVersion, cipherSuite, negotiatedVersion string) { - m.Called(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 @@ -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/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..725e15d 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,46 +198,39 @@ 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 { - 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") + // 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{ @@ -254,6 +247,7 @@ func NewServer( sessionValidator: sessionValidator, operationRecorder: operationRecorder, sessionPersistence: sessionPersistence, + errorRecorder: errorRecorder, orgResolver: orgResolver, sessionSnapshotProvider: sessionSnapshotProvider, propagationSvc: propagationSvc, @@ -262,18 +256,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 +315,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 +337,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 +366,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 +390,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 +398,7 @@ func (s *Server) Stop() error { } s.wg.Wait() - s.logger.Info(LogSCACIServerStopped) + s.logger.InfoContext(s.safeCtx(), LogSCACIServerStopped) return nil } @@ -421,7 +419,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 +508,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 +545,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 +558,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 +569,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 +595,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 +678,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 +697,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 +715,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 +737,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] @@ -753,7 +751,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 +786,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) @@ -805,7 +803,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 +812,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 +840,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 +967,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 +976,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 +1024,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 +1038,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 +1049,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 +1063,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 +1102,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) } } @@ -1128,7 +1126,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) } @@ -1295,7 +1293,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 +1373,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 +1403,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 +1423,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 +1506,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 +1535,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) } @@ -1592,7 +1590,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 +1646,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 +1701,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 @@ -1966,19 +1964,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 +2008,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 +2018,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 +2037,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 fac1313..f678cda 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 } } @@ -740,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 e1e1b3d..6ab0c3f 100644 --- a/KC-Core/pkg/scaci/server_test.go +++ b/KC-Core/pkg/scaci/server_test.go @@ -12,20 +12,21 @@ 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" - "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() } // ============================================================================ @@ -170,7 +171,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") } @@ -179,7 +180,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") } @@ -188,7 +189,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") } @@ -198,7 +199,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") } @@ -208,7 +209,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") } @@ -219,7 +220,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") } @@ -231,7 +232,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") } @@ -244,7 +245,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") } @@ -258,7 +259,65 @@ 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") } + +// 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") +} 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/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/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 new file mode 100644 index 0000000..471fe72 --- /dev/null +++ b/KC-DB/migrations/000139_restore_message_archival.down.sql @@ -0,0 +1,88 @@ +-- 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. + +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; + +-- 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). +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; + + 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); +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..c7d9516 --- /dev/null +++ b/KC-DB/migrations/000139_restore_message_archival.up.sql @@ -0,0 +1,134 @@ +-- 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 (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. +-- +-- 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; +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; + +-- 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) +); + +-- 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; + -- 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. + 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); +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/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..d669b88 --- /dev/null +++ b/KC-DB/migrations/000140_purge_completed_bssci_pending_operations.up.sql @@ -0,0 +1,75 @@ +-- Migration 000140: purge orphaned BSSCI pending operations. +-- +-- 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: +-- 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; + + -- 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 + 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/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/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/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/interfaces/mioty_downlink_repository.go b/KC-DB/storage/interfaces/mioty_downlink_repository.go index e573e79..09dbbc1 100644 --- a/KC-DB/storage/interfaces/mioty_downlink_repository.go +++ b/KC-DB/storage/interfaces/mioty_downlink_repository.go @@ -30,20 +30,31 @@ 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. - // packetCnt is nullable - pass nil if unknown (dlDataQueCmp will update later). + // 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..7829076 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 @@ -298,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/mioty/types.go b/KC-DB/storage/mioty/types.go index e29e8b3..139bfdb 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 @@ -859,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 @@ -880,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 @@ -895,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) @@ -925,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 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/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/archival.go b/KC-DB/storage/postgres/archival.go index 045c50e..21e0e9b 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 { @@ -377,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, @@ -396,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 { @@ -405,42 +447,74 @@ func (s *ArchivalService) GetArchivalStats(ctx context.Context) (*ArchivalStats, if newestMain.Valid { stats.NewestMainMessage = &newestMain.Time } + return nil +} - // Get archive table stats +// 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 + 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 err = s.db.QueryRowContext(ctx, archiveQuery).Scan( - &stats.ArchiveTableCount, - &oldestArchive, - &newestArchive, - &stats.ArchiveTableSize, + &stats.CanonicalArchiveCount, + &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 +} - if oldestArchive.Valid { - stats.OldestArchiveMessage = &oldestArchive.Time +// 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 0, sql.NullTime{}, sql.NullTime{}, fmt.Errorf("check legacy archive presence: %w", err) + } + if legacyExists == nil { + return 0, sql.NullTime{}, sql.NullTime{}, nil } - if newestArchive.Valid { - stats.NewestArchiveMessage = &newestArchive.Time + + 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 - WHERE tablename LIKE 'messages_%' + FROM pg_tables + WHERE tablename LIKE 'messages_%' AND tablename != 'messages_archive' + AND tablename != 'messages_archive_pre000139' ORDER BY tablename` rows, err := s.db.QueryContext(ctx, partitionQuery) @@ -453,19 +527,44 @@ 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 +// 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 @@ -473,14 +572,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 new file mode 100644 index 0000000..04e763b --- /dev/null +++ b/KC-DB/storage/postgres/archival_test.go @@ -0,0 +1,171 @@ +package postgres + +import ( + "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" + + "github.com/Kiloiot/kilo-service-center/KC-Core/pkg/testutil" +) + +// 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 := testutil.TestContext() + + // 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_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 e7ee689..e8e2b6e 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,22 @@ 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) + } + + // 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 @@ -629,7 +600,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 @@ -726,3 +697,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 +} diff --git a/KC-DB/storage/postgres/basestation_repository_test.go b/KC-DB/storage/postgres/basestation_repository_test.go index 36a333d..66d5c27 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) @@ -87,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" @@ -112,10 +126,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 +139,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 +176,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 +218,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 +264,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) @@ -312,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, @@ -368,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, @@ -438,34 +460,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..6fc17f6 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` @@ -175,88 +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 { - updates = append(updates, fmt.Sprintf("ended_at = $%d", argPos)) - args = append(args, *req.EndedAt) - 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) } @@ -273,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 := ` @@ -327,7 +315,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 +325,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 +338,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 +349,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 +381,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 +437,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 +505,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 +764,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` + + 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` - return err + 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/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.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/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/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/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/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/endpoint_repository.go b/KC-DB/storage/postgres/endpoint_repository.go index 67288b7..61b963d 100644 --- a/KC-DB/storage/postgres/endpoint_repository.go +++ b/KC-DB/storage/postgres/endpoint_repository.go @@ -1362,37 +1362,22 @@ 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) + // 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 diff --git a/KC-DB/storage/postgres/endpoint_repository_test.go b/KC-DB/storage/postgres/endpoint_repository_test.go index d88ca2c..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" @@ -218,6 +217,7 @@ func TestCreate_BasicFields(t *testing.T) { EUI: eui, Name: "TestCreate-EP1", TenantID: 500, + EPClass: "A", NwkSnKey: nwkKey, AppKey: appKey, CryptoMode: 0, @@ -1148,6 +1148,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 +1165,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 +1181,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" @@ -1575,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") @@ -1644,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") @@ -1656,6 +1662,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 +1699,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 +1751,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 +1801,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 +1851,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 +1929,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 +1983,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 +2041,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 +2109,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 +2153,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 +2162,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 +2207,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 +2217,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 +2255,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 +2311,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..93051ca 100644 --- a/KC-DB/storage/postgres/endpoint_session_migration_test.go +++ b/KC-DB/storage/postgres/endpoint_session_migration_test.go @@ -1,8 +1,6 @@ package postgres import ( - "context" - "database/sql" "encoding/base64" "fmt" "testing" @@ -10,8 +8,11 @@ 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" + + "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) @@ -36,7 +37,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") @@ -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) @@ -125,7 +126,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") @@ -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) @@ -201,7 +202,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,34 +283,34 @@ 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) { 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/eui64_integration_test.go b/KC-DB/storage/postgres/eui64_integration_test.go new file mode 100644 index 0000000..4b386a6 --- /dev/null +++ b/KC-DB/storage/postgres/eui64_integration_test.go @@ -0,0 +1,188 @@ +package postgres + +import ( + "encoding/binary" + "testing" + "time" + + "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" +) + +// 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)) + } +} + +// 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) { + 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_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/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/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/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/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/migration_discovery_test.go b/KC-DB/storage/postgres/migration_discovery_test.go index a3aa86f..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 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 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, 138, migrations[len(migrations)-1].number, "Last migration should be #138") + assert.Equal(t, 142, migrations[len(migrations)-1].number, "Last migration should be #142") // Verify specific migrations exist expectedMigrations := map[int]string{ @@ -96,6 +97,9 @@ func TestMigrationDiscovery(t *testing.T) { 136: "add_endpoint_blueprint_snapshot", 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-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/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/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/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/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/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/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/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/schema_audit_test.go b/KC-DB/storage/postgres/schema_audit_test.go index 042d0be..4ce2eb1 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" @@ -20,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") @@ -161,7 +169,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/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/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/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/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-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_downlink.go b/KC-DB/storage/postgres/transaction_downlink.go index 2753251..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 (dlDataQueCmp will update later) -// 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, @@ -180,7 +287,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(` @@ -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 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/breaker_interceptor_test.go b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go new file mode 100644 index 0000000..3b3ca19 --- /dev/null +++ b/KC-Gateway/cmd/gateway/breaker_interceptor_test.go @@ -0,0 +1,264 @@ +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_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_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) { + 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 418d723..2581af3 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 { @@ -81,25 +84,53 @@ 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. 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. + switch breaker.State() { + case resilience.BreakerOpen: return status.Error(codes.Unavailable, "upstream circuit breaker is open") - } - if errors.Is(err, gobreaker.ErrTooManyRequests) { + case resilience.BreakerHalfOpen: 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 } } @@ -134,7 +165,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 +176,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 +510,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 +522,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 +544,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/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..87e8539 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 + } + + 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 } - return "localhost:9090" + + 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,117 @@ 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) +} + +// 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())) require.NoError(t, err) defer func() { _ = conn.Close() }() @@ -110,194 +333,50 @@ func TestVerificationMatrix(t *testing.T) { 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-Gateway/internal/resilience/circuit_breaker.go b/KC-Gateway/internal/resilience/circuit_breaker.go index 8e75bb9..38ebf9a 100644 --- a/KC-Gateway/internal/resilience/circuit_breaker.go +++ b/KC-Gateway/internal/resilience/circuit_breaker.go @@ -66,6 +66,22 @@ 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. 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 + }) +} + // State returns the current breaker state. func (b *UpstreamBreaker) State() BreakerState { switch b.cb.State() { 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-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/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-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/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/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..055793b 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" @@ -381,15 +383,21 @@ 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); } 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; @@ -399,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; @@ -418,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/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/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/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..b8a35c5 100644 --- a/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx +++ b/KC-Web/src/modules/endpoints/components/AddEndPointDialog.tsx @@ -24,15 +24,12 @@ 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"; -import { SecurityKeyFields } from "./EndpointFormFields"; import DeviceModelSelector from "./DeviceModelSelector"; +import { SecurityKeyFields } from "./EndpointFormFields"; import EndpointSettingsSection from "./EndpointSettingsSection"; interface AddEndPointDialogProps { 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..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 { @@ -108,9 +109,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/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..258b3fe 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"; @@ -201,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) => @@ -270,7 +213,6 @@ const OrganizationUsers: React.FC = ({ ); }, [users, search]); - // Sort users const sortedUsers = useMemo( () => sortUsers(filteredUsers, orderBy, orderDirection), [filteredUsers, orderBy, orderDirection], @@ -285,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); }; @@ -302,7 +275,6 @@ const OrganizationUsers: React.FC = ({ setRemoveConfirmOpen(true); }; - // Confirm removal const confirmRemove = () => { if (!selectedUser) return; removeOrgUser.mutate( @@ -320,7 +292,6 @@ const OrganizationUsers: React.FC = ({ ); }; - // Close remove confirmation dialog const handleRemoveDialogClose = () => { setRemoveConfirmOpen(false); setSelectedUser(null); @@ -331,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; } @@ -355,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 && ( @@ -414,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 } @@ -425,14 +481,14 @@ const OrganizationUsers: React.FC = ({ {/* Add User Dialog - server admin creates new system user; org admin invites by email */} {isServerAdmin ? ( ) : ( @@ -440,20 +496,20 @@ const OrganizationUsers: React.FC = ({ {/* Edit Organization User Dialog - edits role/permissions of existing member */}
); 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/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); +}; + + 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); +}; + + diff --git a/config/config.docker.yaml b/config/config.docker.yaml index f63776d..5ea1b1b 100644 --- a/config/config.docker.yaml +++ b/config/config.docker.yaml @@ -33,8 +33,17 @@ 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" + 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 diff --git a/pkg/version/release-manifest.json b/pkg/version/release-manifest.json index 66c56f5..1fa0d97 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+c9efdb9", + "buildTime": "2026-07-22T21:02:45Z", + "gitCommit": "c9efdb9618771af93a507c1a20332b027763e1eb", + "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..1fa0d97 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+c9efdb9", + "buildTime": "2026-07-22T21:02:45Z", + "gitCommit": "c9efdb9618771af93a507c1a20332b027763e1eb", + "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."