From 488203e5ba7111e435381b7b1947dbc39bc9bd44 Mon Sep 17 00:00:00 2001 From: madhubabutt <114217841+madhubabutt@users.noreply.github.com> Date: Sat, 16 May 2026 00:10:03 +0530 Subject: [PATCH] RDK-60108 : Refactor and Hardening of Thunder Plugin Interaction for tr69hostif Module (#471) Co-authored-by: mtirum011 --- .github/workflows/L2-tests.yml | 8 + run_l2.sh | 1 + .../tr69hostif_thunder_plugin.feature | 33 ++ .../tests/tr69hostif_thunder_plugin.py | 45 +++ .../native-platform/thunder-mock-server.js | 292 ++++++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 test/functional-tests/features/tr69hostif_thunder_plugin.feature create mode 100644 test/functional-tests/tests/tr69hostif_thunder_plugin.py create mode 100644 test/test-artifacts/native-platform/thunder-mock-server.js diff --git a/.github/workflows/L2-tests.yml b/.github/workflows/L2-tests.yml index abcc19009..0bf905aeb 100644 --- a/.github/workflows/L2-tests.yml +++ b/.github/workflows/L2-tests.yml @@ -40,6 +40,14 @@ jobs: - name: Start l2-container service run: | docker run -d --name native-platform --link mockxconf -v ${{ github.workspace }}:/mnt/L2_CONTAINER_SHARED_VOLUME ghcr.io/rdkcentral/docker-device-mgt-service-test/native-platform:latest + + - name: Copy thunder-mock-server.js file to Native Platform Container + run: | + docker cp ${{ github.workspace }}/test/test-artifacts/native-platform/thunder-mock-server.js native-platform:/usr/local/bin/thunder-mock-server.js + + - name: Run thunder-mock-server.js in background + run: | + docker exec -d native-platform sh -c "node /usr/local/bin/thunder-mock-server.js > /tmp/thunder-mock.log 2>&1" - name: Build tr69hostif and Run L2 inside Native Platform Container run: | diff --git a/run_l2.sh b/run_l2.sh index 553d421e7..325a6d9fa 100644 --- a/run_l2.sh +++ b/run_l2.sh @@ -68,3 +68,4 @@ pytest --json-report --json-report-summary --json-report-file $RESULT_DIR/bootup pytest --json-report --json-report-summary --json-report-file $RESULT_DIR/handlers_communications.json test/functional-tests/tests/test_handlers_communications.py pytest --json-report --json-report-summary --json-report-file $RESULT_DIR/deviceip.json test/functional-tests/tests/tr69hostif_deviceip.py pytest --json-report --json-report-summary --json-report-file $RESULT_DIR/webpa.json test/functional-tests/tests/tr69hostif_webpa.py +pytest --json-report --json-report-summary --json-report-file $RESULT_DIR/thunder_plugin.json test/functional-tests/tests/tr69hostif_thunder_plugin.py diff --git a/test/functional-tests/features/tr69hostif_thunder_plugin.feature b/test/functional-tests/features/tr69hostif_thunder_plugin.feature new file mode 100644 index 000000000..7e450a77e --- /dev/null +++ b/test/functional-tests/features/tr69hostif_thunder_plugin.feature @@ -0,0 +1,33 @@ +#################################################################################### +# If not stated otherwise in this file or this component's Licenses.txt file the +# following copyright and licenses apply: +# +# Copyright 2024 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#################################################################################### + + +Feature: tr69hostif retrieves TR-181 parameters via Thunder plugin JSON-RPC + + Scenario: thunder plugin account id get handler + Given When the tr69hostif binary is invoked + Then the tr69hostif should be running as a daemon + And when the tr69hostif is initialized successfully + Then the tr69hostif validation is done for Thunder plugin AccountID get handlers + + Scenario: thunder plugin experience get handler + Given When the tr69hostif binary is invoked + Then the tr69hostif should be running as a daemon + And when the tr69hostif is initialized successfully + Then the tr69hostif validation is done for Thunder plugin Experience get handlers diff --git a/test/functional-tests/tests/tr69hostif_thunder_plugin.py b/test/functional-tests/tests/tr69hostif_thunder_plugin.py new file mode 100644 index 000000000..051ce4bb7 --- /dev/null +++ b/test/functional-tests/tests/tr69hostif_thunder_plugin.py @@ -0,0 +1,45 @@ +#################################################################################### +# If not stated otherwise in this file or this component's Licenses file the +# following copyright and licenses apply: +# +# Copyright 2024 RDK Management +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#################################################################################### + + +import pytest + +from helper_functions import * + +@pytest.mark.run(order=46) +def test_ThunderPlugin_EXPERIENCE_Get_Handler(): + #clear_tr69hostiflogs() + + DATA_ELEMENT_NAME = "Device.DeviceInfo.X_RDKCENTRAL-COM_Experience" + EXP_MSG = "TESTOS" + + rstdout = rbus_get_data(DATA_ELEMENT_NAME) + assert RBUS_EXCEPTION_STRING not in rstdout + assert EXP_MSG in rstdout + +@pytest.mark.run(order=47) +def test_ThunderPlugin_AccountID_Get_Handler(): + #clear_tr69hostiflogs() + DATA_ELEMENT_NAME = "Device.DeviceInfo.X_RDKCENTRAL-COM_RFC.Feature.AccountInfo.AccountID" + ACCOUNT_ID_MSG = "123456789" + + rstdout = rbus_get_data(DATA_ELEMENT_NAME) + assert RBUS_EXCEPTION_STRING not in rstdout + assert ACCOUNT_ID_MSG in rstdout + diff --git a/test/test-artifacts/native-platform/thunder-mock-server.js b/test/test-artifacts/native-platform/thunder-mock-server.js new file mode 100644 index 000000000..5fa4a5488 --- /dev/null +++ b/test/test-artifacts/native-platform/thunder-mock-server.js @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +/* + * SPDX-License-Identifier: Apache-2.0 + * + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2024 RDK Management + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Thunder JSON-RPC Mock Server + * + * Handles org.rdk.* JSON-RPC 2.0 method calls used by tr69hostif. + * + * Usage: + * node thunder-mock-server.js [--verbose] + * + * Example curl commands: + * + * Thunder JSON-RPC (application/json): + * curl -X POST http://127.0.0.1:9998/jsonrpc \ + * -H 'Content-Type: application/json' \ + * -d '{"jsonrpc":"2.0","id":1,"method":"org.rdk.UserSettings.getPrivacyMode"}' + * + * Thunder JSON-RPC (text/plain - also accepted): + * curl -H 'Content-Type: text/plain' \ + * --data-binary '{"jsonrpc":2.0,"id":15,"method":"org.rdk.UserSettings.getPrivacyMode"}' \ + * http://127.0.0.1:9998/jsonrpc + */ + +'use strict'; + +/* coverity[missing_tls] Suppress missing_tls: This is a test mock server for localhost development only */ +const http = require('http'); +const url = require('url'); + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const VERBOSE = process.argv.includes('--verbose') || process.env.VERBOSE === '1'; +const THUNDER_PORT = Number(process.env.THUNDER_PORT) || 9998; +const THUNDER_HOST = process.env.THUNDER_HOST || '127.0.0.1'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function log(tag, msg) { + if (VERBOSE) { + console.log(`[${tag}] ${msg}`); + } +} + +// --------------------------------------------------------------------------- +// ─── Thunder JSON-RPC Server ──────────────────────────────────────────────── +// --------------------------------------------------------------------------- + +/** + * Mock response database. + * Add entries here to support additional Thunder methods. + */ +const mockResponses = { + 'org.rdk.UserSettings.getPrivacyMode': { + result: 'SHARE', + description: 'User privacy mode setting', + }, + 'org.rdk.System.getPrivacyMode': { + result: 'SHARE', + description: 'System privacy mode setting', + }, + 'org.rdk.System.getPowerState': { + result: { powerState: 'STANDBY' }, + description: 'Current system power state', + }, + 'org.rdk.NetworkManager.GetPrimaryInterface': { + result: { interface: 'eth0' }, + description: 'Primary network interface', + }, + 'org.rdk.NetworkManager.GetIPSettings': { + result: { ipaddress: '192.168.1.100' }, + description: 'IP settings for interface', + }, + 'org.rdk.AuthService.getServiceAccountId': { + result: { serviceAccountId: '123456789' }, + description: 'Service account ID', + }, + 'org.rdk.AuthService.getExperience': { + result: { experience: 'TESTOS' }, + description: 'Device experience profile', + }, + 'org.rdk.Account.getLastCheckoutResetTime': { + result: { resetTime: Math.floor(Date.now() / 1000) }, + description: 'Last checkout reset timestamp', + }, +}; + +/** + * Validate a parsed JSON-RPC request object. + * Accepts jsonrpc as either the string "2.0" or the number 2.0 so that + * clients which omit quotes (e.g. --data-binary with text/plain) still work. + * + * @param {*} data + * @returns {{ valid: boolean, error: string|null }} + */ +function validateJsonRpcRequest(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { valid: false, error: 'Request is not a valid JSON object' }; + } + + // Accept "2.0" (string) or 2.0 (number) + const ver = data.jsonrpc; + if (ver !== '2.0' && ver !== 2.0 && ver !== 2) { + return { valid: false, error: 'Invalid JSON-RPC version (expected "2.0")' }; + } + + if (!data.method || typeof data.method !== 'string') { + return { valid: false, error: 'Missing or invalid "method" field' }; + } + + if (data.id === undefined) { + return { valid: false, error: 'Missing required "id" field' }; + } + + return { valid: true, error: null }; +} + +function createErrorResponse(id, code, message) { + return { jsonrpc: '2.0', id: id !== undefined ? id : null, error: { code, message } }; +} + +function createSuccessResponse(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +/** + * Dispatch a validated JSON-RPC request to the mock response database. + */ +function dispatchJsonRpcRequest(request) { + const { id, method, params } = request; + + log('RPC', `method="${method}" id=${id} params=${JSON.stringify(params || {})}`); + + const entry = mockResponses[method]; + if (!entry) { + log('RPC', `Method not found: "${method}"`); + return createErrorResponse(id, -32601, `Method not found: ${method}`); + } + + const response = createSuccessResponse(id, entry.result); + log('RPC', `result=${JSON.stringify(entry.result)}`); + return response; +} + +/** + * Handle POST /jsonrpc - read body, parse JSON, dispatch. + */ +function handleJsonRpcPost(req, res) { + let bodyData = ''; + + req.on('data', (chunk) => { + if (bodyData.length + chunk.length > 1024 * 1024) { + req.pause(); + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(createErrorResponse(null, -32700, 'Request entity too large'))); + return; + } + bodyData += chunk.toString('utf8'); + }); + + req.on('end', () => { + let requestObject; + + try { + requestObject = JSON.parse(bodyData); + } catch (parseError) { + log('RPC', `JSON parse failed: ${parseError.message}`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(createErrorResponse(null, -32700, 'Parse error'))); + return; + } + + const validation = validateJsonRpcRequest(requestObject); + if (!validation.valid) { + log('RPC', `Validation failed: ${validation.error}`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(createErrorResponse(requestObject.id, -32600, validation.error))); + return; + } + + const response = dispatchJsonRpcRequest(requestObject); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + }); + + req.on('error', (error) => { + console.error(`[RPC] Stream error: ${error.message}`); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + }); +} + +/** + * Main Thunder HTTP request router. + */ +function thunderRequestHandler(req, res) { + const parsedUrl = url.parse(req.url, true); + + log('THUNDER', `${req.method} ${req.url} from ${req.socket.remoteAddress}`); + + if (parsedUrl.pathname === '/jsonrpc') { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed - use POST' })); + return; + } + return handleJsonRpcPost(req, res); + } + + if (req.method === 'GET') { + if (parsedUrl.pathname === '/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'running', port: THUNDER_PORT, methods: Object.keys(mockResponses) })); + return; + } + if (parsedUrl.pathname === '/methods') { + const methods = Object.entries(mockResponses).map(([name, data]) => ({ + method: name, + description: data.description, + result: data.result, + })); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(methods)); + return; + } + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Endpoint not found' })); +} + +/** + * Start the Thunder HTTP server. + */ +function startThunderServer() { + /* coverity[missing_tls] : FP - loopback-only test mock; mirrors Thunder daemon's localhost HTTP convention */ + // noinspection JSUnresolvedReference - Thunder mock for localhost development + // eslint-disable-next-line no-undef + const server = http.createServer(thunderRequestHandler); + /* coverity[missing_tls] */ + + server.on('error', (error) => { + console.error(`[THUNDER] Server error: ${error.message}`); + process.exit(1); + }); + + server.listen(THUNDER_PORT, THUNDER_HOST, () => { + console.log(`[THUNDER] JSON-RPC Mock Server running at http://${THUNDER_HOST}:${THUNDER_PORT}/jsonrpc`); + }); + + return server; +} + +// --------------------------------------------------------------------------- +// ─── Main ─────────────────────────────────────────────────────────────────── +// --------------------------------------------------------------------------- + +const thunderServer = startThunderServer(); + +function gracefulShutdown(signal) { + console.log(`\n${signal} received, shutting down...`); + thunderServer.close(() => { + console.log('Server closed.'); + process.exit(0); + }); +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); +