diff --git a/src/conf_defaults.lua b/src/conf_defaults.lua new file mode 100644 index 0000000..0b7bdf4 --- /dev/null +++ b/src/conf_defaults.lua @@ -0,0 +1,34 @@ +local M = {} + +--NOTE: pcall protects from invocation exceptions, which is what we need except +--during debugging. This flag replaces them with a normal call so we can inspect stack traces. +M.DEBUG_PCALLS = true + +--REST responses will contain 'module' and 'function' keys describing what was requested +M.API_INCLUDE_ENDPOINT_INFO = false + +M.DEFAULT_AP_SSID = "d3d-ap-%MAC_ADDR_TAIL%" +M.DEFAULT_AP_ADDRESS = "192.168.10.1" +M.DEFAULT_AP_NETMASK = "255.255.255.0" + + +--NOTE: proposed notation for baseline configuration (containing defaults as well as type and constraint information) +--the table name is the configuration key; min, max and regex are all optional; type is one of: {int, float, string, ...?} +M.temperature = { + default = 230, + type = 'int', + description = '...xyzzy', + min = 0, + max = 350 +} + +M.ssid = { + default = 'd3d-ap-%%MAC_TAIL%%', + type = 'int', --one of: {int, float, string, ...?} + min = 1, + max = 32, + regex = '[a-zA-Z0-9 -=+]+' +} + + +return M diff --git a/src/config.lua b/src/config.lua deleted file mode 100644 index 58f9d7d..0000000 --- a/src/config.lua +++ /dev/null @@ -1,14 +0,0 @@ -local M = {} - ---NOTE: pcall protects from invocation exceptions, which is what we need except ---during debugging. This flag replaces them with a normal call so we can inspect stack traces. -M.DEBUG_PCALLS = true - ---REST responses will contain 'module' and 'function' keys describing what was requested -M.API_INCLUDE_ENDPOINT_INFO = false - -M.DEFAULT_AP_SSID = "d3d-ap-%MAC_ADDR_TAIL%" -M.DEFAULT_AP_ADDRESS = "192.168.10.1" -M.DEFAULT_AP_NETMASK = "255.255.255.0" - -return M diff --git a/src/main.lua b/src/main.lua index bbe7d39..d5ea59f 100644 --- a/src/main.lua +++ b/src/main.lua @@ -1,11 +1,12 @@ package.path = package.path .. ';/usr/share/lua/wifibox/?.lua' -local l = require("logger") -local RequestClass = require("rest.request") -local ResponseClass = require("rest.response") -local wifi = require("network.wlanconfig") -local netconf = require("network.netconfig") -local config = require("config") +local config = require('config') +local u = require('util.utils') +local l = require('util.logger') +local wifi = require('network.wlanconfig') +local netconf = require('network.netconfig') +local RequestClass = require('rest.request') +local ResponseClass = require('rest.response') local postData = nil @@ -22,8 +23,8 @@ local function init() else l:info("Wifibox CGI handler started") end - if (os.getenv("REQUEST_METHOD") == "POST") then - local n = tonumber(os.getenv("CONTENT_LENGTH")) + if (os.getenv('REQUEST_METHOD') == 'POST') then + local n = tonumber(os.getenv('CONTENT_LENGTH')) postData = io.read(n) end @@ -41,14 +42,14 @@ end local rq = RequestClass.new(postData, config.DEBUG_PCALLS) l:info("received request of type " .. rq:getRequestMethod() .. " for " .. (rq:getRequestedApiModule() or "") - .. "/" .. (rq:getRealApiFunctionName() or "") .. " with arguments: " .. l:dump(rq:getAll())) - if rq:getRequestMethod() ~= "CMDLINE" then + .. "/" .. (rq:getRealApiFunctionName() or "") .. " with arguments: " .. u.dump(rq:getAll())) + if rq:getRequestMethod() ~= 'CMDLINE' then l:info("remote IP/port: " .. rq:getRemoteHost() .. "/" .. rq:getRemotePort()) l:debug("user agent: " .. rq:getUserAgent()) end - if (not config.DEBUG_PCALLS and rq:getRequestMethod() == "CMDLINE") then - if rq:get("autowifi") ~= nil then + if (not config.DEBUG_PCALLS and rq:getRequestMethod() == 'CMDLINE') then + if rq:get('autowifi') ~= nil then setupAutoWifiMode() else l:info("Nothing to do...bye.\n") diff --git a/src/network/netconfig.lua b/src/network/netconfig.lua index c350638..8e7c9de 100644 --- a/src/network/netconfig.lua +++ b/src/network/netconfig.lua @@ -1,6 +1,6 @@ local config = require("config") local u = require("util.utils") -local l = require("logger") +local l = require("util.logger") local uci = require("uci").cursor() local M = {} diff --git a/src/network/wlanconfig.lua b/src/network/wlanconfig.lua index 46ace29..2256417 100644 --- a/src/network/wlanconfig.lua +++ b/src/network/wlanconfig.lua @@ -1,5 +1,5 @@ local util = require("util.utils") -local l = require("logger") +local l = require("util.logger") local uci = require("uci").cursor() local iwinfo = require("iwinfo") diff --git a/src/rest/api/api_network.lua b/src/rest/api/api_network.lua index aee773b..9351e09 100644 --- a/src/rest/api/api_network.lua +++ b/src/rest/api/api_network.lua @@ -1,6 +1,6 @@ local config = require("config") -local l = require("logger") local u = require("util.utils") +local l = require("util.logger") local netconf = require("network.netconfig") local wifi = require("network.wlanconfig") local ResponseClass = require("rest.response") @@ -36,7 +36,7 @@ function M.available(request, response) netInfo["signal"] = se.signal netInfo["quality"] = se.quality netInfo["quality_max"] = se.quality_max - if withRaw then netInfo["_raw"] = l:dump(se) end + if withRaw then netInfo["_raw"] = u.dump(se) end table.insert(netInfoList, netInfo) end @@ -63,7 +63,7 @@ function M.known(request, response) netInfo["bssid"] = net.bssid or "" netInfo["channel"] = net.channel or "" netInfo["encryption"] = net.encryption - if withRaw then netInfo["_raw"] = l:dump(net) end + if withRaw then netInfo["_raw"] = u.dump(net) end table.insert(netInfoList, netInfo) end end @@ -87,7 +87,7 @@ function M.state(request, response) response:addData("txpower", ds.txpower) response:addData("signal", ds.signal) response:addData("noise", ds.noise) - if withRaw then response:addData("_raw", l:dump(ds)) end + if withRaw then response:addData("_raw", u.dump(ds)) end end --UNTESTED diff --git a/src/rest/api/api_test.lua b/src/rest/api/api_test.lua index 13712f4..c01f454 100644 --- a/src/rest/api/api_test.lua +++ b/src/rest/api/api_test.lua @@ -1,4 +1,4 @@ -local l = require("logger") +local l = require("util.logger") local ResponseClass = require("rest.response") local M = {} diff --git a/src/test/ansicolors.lua b/src/test/ansicolors.lua new file mode 100644 index 0000000..47d566f --- /dev/null +++ b/src/test/ansicolors.lua @@ -0,0 +1,63 @@ +-- adapted from: http://lua-users.org/wiki/AnsiTerminalColors +local pairs = pairs +local tostring = tostring +local setmetatable = setmetatable +local schar = string.char + +local colormt = {} + +function colormt:__tostring() + return self.value +end + +function colormt:__concat(other) + return tostring(self) .. tostring(other) +end + +function colormt:__call(s) + return self .. s .. colormt.reset +end + +colormt.__metatable = {} + +local function makecolor(value) + return setmetatable({ value = schar(27) .. '[' .. tostring(value) .. 'm' }, colormt) +end + +local colors = { + -- attributes + reset = 0, + clear = 0, + bright = 1, + dim = 2, + underscore = 4, + blink = 5, + reverse = 7, + hidden = 8, + + -- foreground + black = 30, + red = 31, + green = 32, + yellow = 33, + blue = 34, + magenta = 35, + cyan = 36, + white = 37, + + -- background + onblack = 40, + onred = 41, + ongreen = 42, + onyellow = 43, + onblue = 44, + onmagenta = 45, + oncyan = 46, + onwhite = 47, +} + +for c, v in pairs(colors) do + colormt[c] = makecolor(v) +end + +return colormt diff --git a/src/test/test_utils.lua b/src/test/test_utils.lua new file mode 100644 index 0000000..7faaac8 --- /dev/null +++ b/src/test/test_utils.lua @@ -0,0 +1,105 @@ +local utils = require("util.utils") + +local M = { + _is_test = true, + _skip = { 'dump', 'symlink', 'getUciSectionName', 'symlinkInRoot' }, + _wifibox_only = { 'getUciSectionName', 'symlinkInRoot' } +} + +local function compareTables(t1, t2) + if #t1 ~= #t2 then return false end + for i=1,#t1 do + if t1[i] ~= t2[i] then return false end + end + return true +end + +-- Returns a string representation of the argument, with 'real' strings enclosed in single quotes +local function stringRepresentation(v) + return type(v) == 'string' and ("'"..v.."'") or tostring(v) +end + + +local filename = "/tmp/somefile12345.txt" + + +function M:_setup() + os.execute("rm -f " .. filename) -- make sure the file does not exist +end + +function M:_teardown() + os.execute("rm -f " .. filename) -- make sure the file gets removed again +end + + +function M:test_splitString() + local input1, input2, input3 = ':a:b::', '/a/b//', '$a$b$$' + local expected = { '', 'a', 'b', '', '' } + + local result1 = input1:split() + local result2 = input2:split('/') + local result3 = input3:split('$') + + assert(#result1 == 5) + assert(compareTables(result1, expected)) + assert(#result2 == 5) + assert(compareTables(result2, expected)) + assert(#result3 == 5) + assert(compareTables(result3, expected)) +end + +function M:test_toboolean() + local trues = { true, 1, 'true', 'True', 'T', '1' } + local falses = { nil, false, 0, 'false', 'False' , 'f', {} } + + for _,v in pairs(trues) do assert(utils.toboolean(v), "expected true: " .. stringRepresentation(v)) end + for _,v in pairs(falses) do assert(not utils.toboolean(v), "expected false: " .. stringRepresentation(v)) end +end + +function M:test_dump() + --test handling of reference loops + assert(false, 'not implemented') +end + +function M:test_getUciSectionName() + assert(false, 'not implemented') +end + +function M:test_exists() + assert(utils.exists() == nil) + assert(utils.exists(nil) == nil) + + assert(not utils.exists(filename)) + os.execute("touch " .. filename) + assert(utils.exists(filename)) +end + +function M:test_create() + local f, testContents = nil, 'test text' + + assert(utils.create() == nil) + assert(utils.create(nil) == nil) + + assert(not io.open(filename, 'r')) + utils.create(filename) + assert(io.open(filename, 'r')) + + f = io.open(filename, 'w') + f:write(testContents) + f:close() + + utils.create(filename) + f = io.open(filename, 'r') + local actualContents = f:read('*all') + assert(actualContents == testContents) +end + +function M:test_symlink() + assert(false, 'not implemented') +end + +function M:test_symlinkInRoot() + assert(false, 'not implemented') +end + +return M diff --git a/src/test/testrunner.lua b/src/test/testrunner.lua new file mode 100644 index 0000000..21dc253 --- /dev/null +++ b/src/test/testrunner.lua @@ -0,0 +1,87 @@ +package.path = package.path .. ';./test/?.lua' +local ansicolors = require('test.ansicolors') + +local testFunctionPrefix = 'test_' + +local function tableIndexOf(t, val) + for k,v in ipairs(t) do + if v == val then return k end + end + return -1 +end + +local function runningOnWifibox() + local f,e = io.open('/etc/openwrt_release', 'r') + return f ~= nil +end + +local function runTestFile(filename, showStackTraces) + local r,tf = pcall(require, filename) + local stackTrace, errorMessage + + local function errorHandler(msg) + stackTrace = debug.traceback() + errorMessage = msg + end + + if not r then return nil,tf end + if not tf._is_test then return nil,"not a test file" end + + local setupFunc, teardownFunc = tf._setup, tf._teardown + + print("======= running test file '" .. filename .. "' =======") + + for k,v in pairs(tf) do + --if type(v) == 'function' and k ~= '_setup' and k ~= '_teardown' then + if type(v) == 'function' and k:find(testFunctionPrefix) == 1 then + local baseName = k:sub(testFunctionPrefix:len() + 1) + local skip = (tableIndexOf(tf._skip, baseName) > -1) + local wifiboxOnly = (tableIndexOf(tf._wifibox_only, baseName) > -1) + + if not skip and (not wifiboxOnly or runningOnWifibox()) then + pcall(setupFunc) + local testResult = xpcall(v, errorHandler) + pcall(teardownFunc) + + if testResult then + print(ansicolors.green .. "[OK ] " .. ansicolors.reset .. k) + else + print(ansicolors.red .. "[ERR] " .. ansicolors.reset .. k) + if errorMessage then print(" " .. errorMessage) end + if showStackTraces and stackTrace then print(" " .. stackTrace) end + end + else + if skip then + print(ansicolors.bright .. ansicolors.black .. "[SKP] " .. ansicolors.reset .. k) + else + print(ansicolors.yellow .. "[WBO] " .. ansicolors.reset .. k) + end + end + end + end + + print("") + + return true +end + + +local function main() + if #arg < 1 then + print("Please specify at least one lua test file") + os.exit(1) + end + + for i=1,#arg do + local modName = 'test_'..arg[i] + local r,e = runTestFile(modName, true) + + if not r then + io.stderr:write("test file '" .. modName .. "' could not be loaded or is not a test file ('" .. e .. "')\n") + end + end + + os.exit(0) +end + +main(arg) diff --git a/src/test/www/restapi.css b/src/test/www/restapi.css new file mode 100644 index 0000000..73b102c --- /dev/null +++ b/src/test/www/restapi.css @@ -0,0 +1,43 @@ +body { + width: 100%; +} + +div#content { + width: 80%; + margin: auto; +} + +div.module { + border: 1px solid #eee; + margin-bottom: 1em; +} + +.title { + padding-left: 2em; + font-size: larger; + display: block; +} + +.resp_success { + color: green; +} + +.resp_status { + margin-right: 1em; +} + +.resp_fail { + color: yellow; +} + +.resp_error { + color: red; +} + +.resp_unknown { + color: purple; +} + +span.resp_msg { + padding-left: 1em; +} diff --git a/src/test/www/restapi.html b/src/test/www/restapi.html new file mode 100644 index 0000000..28cb371 --- /dev/null +++ b/src/test/www/restapi.html @@ -0,0 +1,22 @@ + + + + + Test page for the Doodle3D WiFiBox REST API + + + + +
+
+ Test API +
+
+ Network API +
+ +
+ Run arbitrary requests +
+
+ diff --git a/src/test/www/restapi.js b/src/test/www/restapi.js new file mode 100644 index 0000000..8758131 --- /dev/null +++ b/src/test/www/restapi.js @@ -0,0 +1,40 @@ +const API_ROOT = "/cgi-bin/d3dapi"; + +$(document).ready(function(){ + performRequest('test', '123', "GET", {}); + performRequest('test', '', "POST", {}); + performRequest('test', 'success', "GET", {}); + performRequest('test', 'fail', "GET", {}); + performRequest('test', 'error', "GET", {}); + performRequest('test', 'echo', "GET", {}); + performRequest('test', 'write', "POST", {}); + performRequest('test', 'read', "GET", {}); +}); + +/* + * TODO + * - change function signature to: apiPath, data, expected + * - create request divs before ajax request so it's visible what 'threads' have been put out + * - function always tries both GET and POST and matches this with expected.methods + * - expected.methods can be 'GET', 'POST' or 'BOTH' + * - expected.status indicates what status was expected + * - expected.data.{...} indicates what fields should look like (regex/literal/...?) + * - anything not specified in expected.data is ignored + */ +function performRequest(mod, func, method, data) { + $.ajax({ + type: method, + context: $("#module_" + mod), + url: API_ROOT + "/" + mod + "/" + func, + dataType: 'json', + data: data + }).done(function(response){ + var status = response.status == 'success' ? '+' + : response.status == 'fail' ? '-' + : response.status == 'error' ? '!' + : '?'; + var modFunc = '' + mod + '/' + func + '
'; + this.append('
' + status + modFunc + + '' + response.msg + '
'); + }); +} diff --git a/src/logger.lua b/src/util/logger.lua similarity index 69% rename from src/logger.lua rename to src/util/logger.lua index 2af1ed0..e37c6fb 100644 --- a/src/logger.lua +++ b/src/util/logger.lua @@ -1,10 +1,12 @@ +local utils = require('util.utils') + local M = {} local logLevel, logVerbose, logStream -M.LEVEL = {"debug", "info", "warn", "error", "fatal"} +M.LEVEL = {'debug', 'info', 'warn', 'error', 'fatal'} ---M.LEVEL already has idx=>name entries, now create name=>idx entries +-- M.LEVEL already has idx=>name entries, now create name=>idx entries for i,v in ipairs(M.LEVEL) do M.LEVEL[v] = i end @@ -15,38 +17,25 @@ function M:init(level, verbose) logStream = stream or io.stdout end ---pass nil as stream to reset to stdout +-- pass nil as stream to reset to stdout function M:setStream(stream) logStream = stream or io.stdout end local function log(level, msg, verbose) if level >= logLevel then - local now = os.date("%m-%d %H:%M:%S") + local now = os.date('%m-%d %H:%M:%S') local i = debug.getinfo(3) --the stack frame just above the logger call local v = verbose if v == nil then v = logVerbose end local name = i.name or "(nil)" - local vVal = "nil" - local m = (type(msg) == "string") and msg or M:dump(msg) + local vVal = 'nil' + local m = (type(msg) == 'string') and msg or utils.dump(msg) if v then logStream:write(now .. " (" .. M.LEVEL[level] .. ") " .. m .. " [" .. name .. "@" .. i.short_src .. ":" .. i.linedefined .. "]\n") else logStream:write(now .. " (" .. M.LEVEL[level] .. ") " .. m .. "\n") end end end -function M:dump(o) - if type(o) == 'table' then - local s = '{ ' - for k,v in pairs(o) do - if type(k) ~= 'number' then k = '"'..k..'"' end - s = s .. '['..k..'] = ' .. M:dump(v) .. ',' - end - return s .. '} ' - else - return tostring(o) - end -end - function M:debug(msg, verbose) log(M.LEVEL.debug, msg, verbose); return true end function M:info(msg, verbose) log(M.LEVEL.info, msg, verbose); return true end function M:warn(msg, verbose) log(M.LEVEL.warn, msg, verbose); return true end diff --git a/src/util/utils.lua b/src/util/utils.lua index 8d12215..6723b83 100644 --- a/src/util/utils.lua +++ b/src/util/utils.lua @@ -1,9 +1,9 @@ -local uci = require("uci").cursor() +local uci = require('uci').cursor() local M = {} function string:split(div) - local div, pos, arr = div or ":", 0, {} + local div, pos, arr = div or ':', 0, {} for st,sp in function() return self:find(div, pos, true) end do table.insert(arr, self:sub(pos, st - 1)) pos = sp + 1 @@ -15,26 +15,63 @@ end function M.toboolean(s) if not s then return false end - local b = s:lower() - return (b == "1" or b == "t" or b == "true") and true or false + local b = type(s) == 'string' and s:lower() or s + local textTrue = (b == '1' or b == 't' or b == 'true') + local boolTrue = (type(b) == 'boolean' and b == true) + local numTrue = (type(b) == 'number' and b > 0) + return textTrue or boolTrue or numTrue +end + +function M.dump(o) + if type(o) == 'table' then + local s = '{ ' + for k,v in pairs(o) do + if type(k) ~= 'number' then k = '"'..k..'"' end + s = s .. '['..k..'] = ' .. M.dump(v) .. ',' + end + return s .. '} ' + else + return tostring(o) + end end function M.getUciSectionName(config, type) local sname = nil - uci:foreach(config, type, function(s) sname = s[".name"] end) + uci:foreach(config, type, function(s) sname = s['.name'] end) return sname end function M.exists(file) - local r = io.open(file) --ignore returned message - if r ~= nil then io.close(r) end + if not file or type(file) ~= 'string' or file:len() == 0 then + return nil, "file must be a non-empty string" + end + + local r = io.open(file, 'r') -- ignore returned message + if r then r:close() end return r ~= nil end +--creates and returns true if not exists, returns false it does, nil+msg on error +function M.create(file) + local r,m = M.exists(file) + + if r == nil then + return r,m + elseif r == true then + return true + end + + r,m = io.open(file, 'a') -- append mode is probably safer in case the file does exist after all + if not r then return r,m end + + r:close() + return true +end + --FIXME: somehow protect this function from running arbitrary commands function M.symlink(from, to) - if from == nil or from == "" or to == nil or to == "" then return -1 end - local x = "ln -s " .. from .. " " .. to + if from == nil or from == '' or to == nil or to == '' then return -1 end + local x = 'ln -s ' .. from .. ' ' .. to return os.execute(x) end