From 1d4789be46dc7eb79bf384b1ad366cfff3a61bb6 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Wed, 17 Jun 2026 23:21:12 +0200 Subject: [PATCH] feat(cloud): verify login password --- manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-cloud/apis/libcloud.lua | 28 +- packages/trapos-cloud/ccpm.json | 2 +- packages/trapos-cloud/programs/cloud.lua | 112 ++++++- packages/trapos-cloud/tests/cloud-program.lua | 295 ++++++++++++++++-- packages/trapos-cloud/tests/cloud.lua | 75 +++++ packages/trapos/ccpm.json | 2 +- 8 files changed, 481 insertions(+), 39 deletions(-) diff --git a/manifest.json b/manifest.json index 80f904b..4a5b84c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.12.2", + "version": "0.12.3", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index c60c959..fec5aaf 100644 --- a/packages/index.json +++ b/packages/index.json @@ -6,8 +6,8 @@ "trapos-net": "0.3.2", "trapos-ui": "0.2.4", "trapos-ai": "0.8.0", - "trapos-cloud": "0.4.2", + "trapos-cloud": "0.4.3", "trapos-sandbox-legacy": "0.3.2", - "trapos": "0.12.2" + "trapos": "0.12.3" } } diff --git a/packages/trapos-cloud/apis/libcloud.lua b/packages/trapos-cloud/apis/libcloud.lua index 66e295d..ef43f0d 100644 --- a/packages/trapos-cloud/apis/libcloud.lua +++ b/packages/trapos-cloud/apis/libcloud.lua @@ -210,6 +210,13 @@ local function createCloud() -- (unauthorized and displaced are terminal: no reconnect.) local state = 'connecting'; local lastError = nil; + -- Login verification overrides (set by a trapos_cloud_reconnect carrying a candidate + -- secret + correlation token). secretOverride wins over getSecret() in connect() so the + -- candidate is tried without being persisted; verifyToken is echoed on the verdict events + -- so `cloud login` can match its own attempt. Both survive websocket retries during the + -- verify window; a plain (candidate-less) reconnect clears them. + local secretOverride = nil; + local verifyToken = nil; local activeWs = nil; local suppressNextClose = false; local clearReconnectTimeout = nil; @@ -241,7 +248,7 @@ local function createCloud() clearReconnectTimeout(); clearReconnectTimeout = nil; end - secret = getSecret(); + secret = secretOverride or getSecret(); state = 'connecting'; if not reconnecting then log('info', 'connecting', { url = url }); @@ -287,11 +294,17 @@ local function createCloud() else log('info', 'connected', { traposId = traposId }); end - queueEvent('trapos_cloud_connected', traposId); + -- Echo the candidate's verification token (nil for ordinary reconnects), then drop it + -- so later natural reconnects don't re-emit a stale token. secretOverride is kept: it + -- equals the secret `cloud login` is about to persist, so it stays a safe no-op until a + -- plain reconnect clears it. + queueEvent('trapos_cloud_connected', traposId, verifyToken); + verifyToken = nil; else state = 'unauthorized'; lastError = (type(err) == 'table' and err.code) or 'unauthorized'; log('warn', 'hello rejected; yielding', { code = lastError }); + queueEvent('trapos_cloud_unauthorized', traposId, lastError, verifyToken); closeActive(); end end @@ -399,7 +412,16 @@ local function createCloud() sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err)); end); - el.register('trapos_cloud_reconnect', function(reason) + el.register('trapos_cloud_reconnect', function(reason, candidateSecret, token) + -- A candidate secret arms login verification; anything else (plain reconnect) clears it, + -- so login-restore / login-saved / manual reconnects fall back to the persisted secret. + if type(candidateSecret) == 'string' and candidateSecret ~= '' then + secretOverride = candidateSecret; + verifyToken = token; + else + secretOverride = nil; + verifyToken = nil; + end reconnectNow(reason); end); diff --git a/packages/trapos-cloud/ccpm.json b/packages/trapos-cloud/ccpm.json index 677d479..856dc2e 100644 --- a/packages/trapos-cloud/ccpm.json +++ b/packages/trapos-cloud/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-cloud", - "version": "0.4.2", + "version": "0.4.3", "description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos-cloud/programs/cloud.lua b/packages/trapos-cloud/programs/cloud.lua index e9e742f..a15f008 100644 --- a/packages/trapos-cloud/programs/cloud.lua +++ b/packages/trapos-cloud/programs/cloud.lua @@ -1,9 +1,13 @@ local createCloud = require('/apis/libcloud'); local createVersion = require('/apis/libversion'); +local createEventLoop = require('/apis/eventloop'); local URL_SETTING = 'cloud.url'; local PASSWORD_SETTING = 'cloud.password'; local RECONNECT_EVENT = 'trapos_cloud_reconnect'; +local VERIFY_TIMEOUT = 12; -- one hello attempt; daemon helloTimeout is 10s + +local cloudApi = createCloud(); local rawArgs = table.pack(...); local command = rawArgs[1]; @@ -70,7 +74,7 @@ if command == 'status' then return; end - local ok, result = createCloud().request('probe_server'); + local ok, result = cloudApi.request('probe_server'); if not ok then local code = (type(result) == 'table' and result.code) or 'internal'; local message = (type(result) == 'table' and result.message) or ''; @@ -99,17 +103,107 @@ if command == 'login' then return; end - local password = opts.password or readPassword(); - if isBlank(password) then - print('cloud.password not changed: password cannot be empty'); + if isBlank(settings.get(URL_SETTING)) then + print('set cloud.url first'); return; end - settings.set(PASSWORD_SETTING, password); - settings.save(); - os.queueEvent(RECONNECT_EVENT); - print('cloud.password set'); - return; + -- Ask the daemon to reconnect with `candidate` (never persisted) and wait for the handshake + -- verdict carrying our token. Returns 'ok' | 'unauthorized' | 'timeout' | 'terminate'. Other + -- daemon verdicts (tokenless or for a different token) are ignored. + local function verifyPassword(candidate) + local token = cloudApi.uuid(); + local result = nil; + local el = createEventLoop(); + + local function finish(value) + if result ~= nil then return; end + result = value; + el.stopLoop(); + end + + el.register('trapos_cloud_connected', function(_traposId, eventToken) + if eventToken == token then finish('ok'); end + end); + el.register('trapos_cloud_unauthorized', function(_traposId, _err, eventToken) + if eventToken == token then finish('unauthorized'); end + end); + -- terminate stops the default loop on its own; just record it for the caller. + el.register('terminate', function() result = 'terminate'; end); + el.setTimeout(function() finish('timeout'); end, VERIFY_TIMEOUT); + + os.queueEvent(RECONNECT_EVENT, 'login-verify', candidate, token); + el.runLoop(); + return result; + end + + local function restore() + os.queueEvent(RECONNECT_EVENT, 'login-restore'); + end + + -- Persist only after a verified password. Roll the live setting back if the save fails so the + -- daemon (which reads live settings) never keeps an unsaved secret. No reconnect on success: + -- the live socket already authenticated with this secret. + local function savePassword(password) + local old = settings.get(PASSWORD_SETTING); + settings.set(PASSWORD_SETTING, password); + if not settings.save() then + if old ~= nil then + settings.set(PASSWORD_SETTING, old); + else + settings.unset(PASSWORD_SETTING); + end + restore(); + error('cloud login failed: could not save settings', 0); + end + print('logged in'); + end + + if opts.password == nil then + -- Interactive: re-prompt on a wrong password; ctrl+t cancels. + while true do + local candidate = readPassword(); + if isBlank(candidate) then + print('password cannot be empty'); + else + local result = verifyPassword(candidate); + if result == 'ok' then + savePassword(candidate); + return; + elseif result == 'unauthorized' then + restore(); + print('invalid password, try again (ctrl+t to cancel)'); + elseif result == 'timeout' then + restore(); + print('cloud login failed: could not reach gateway'); + return; + else -- terminate + restore(); + error('Terminated', 0); + end + end + end + end + + -- Non-interactive: a wrong password fails with a non-zero exit. + if isBlank(opts.password) then + error('cloud login failed: password cannot be empty', 0); + end + + local result = verifyPassword(opts.password); + if result == 'ok' then + savePassword(opts.password); + return; + elseif result == 'unauthorized' then + restore(); + error('cloud login failed: invalid password', 0); + elseif result == 'timeout' then + restore(); + error('cloud login failed: could not reach gateway', 0); + else -- terminate + restore(); + error('Terminated', 0); + end end printUsage(); diff --git a/packages/trapos-cloud/tests/cloud-program.lua b/packages/trapos-cloud/tests/cloud-program.lua index 1cb58ba..1aea2e2 100644 --- a/packages/trapos-cloud/tests/cloud-program.lua +++ b/packages/trapos-cloud/tests/cloud-program.lua @@ -16,23 +16,96 @@ local function containsText(lines, expected) return false; end -local function fakeSettings(values) +local RECONNECT_EVENT = 'trapos_cloud_reconnect'; + +local function fakeSettings(values, saveOk) return { values = values or {}, saveCount = 0, + saveOk = saveOk ~= false, get = function(self, key) return self.values[key]; end, set = function(self, key, value) self.values[key] = value; end, + unset = function(self, key) + self.values[key] = nil; + end, save = function(self) self.saveCount = self.saveCount + 1; - return true; + return self.saveOk; end, }; end +-- Find the token from the most recent login-verify reconnect, so scripted verdicts can echo the +-- exact token the program generated for the current attempt (no test-only seam in the program). +local function latestVerifyToken(queuedEvents) + for i = #queuedEvents, 1, -1 do + local e = queuedEvents[i]; + if e[1] == RECONNECT_EVENT and e[2] == 'login-verify' then + return e[4]; + end + end + return nil; +end + +-- Fake eventloop standing in for /apis/eventloop. Each runLoop() consumes the next entry from +-- `attempts` (one per verifyPassword call) and dispatches the listed verdict steps to the +-- program's registered handlers, echoing the captured token. A matching verdict / timeout stops +-- the loop (the program's finish() calls stopLoop); a mismatched/tokenless step is dispatched but +-- ignored by the program, so the loop continues to the next step. +local function makeFakeEventloop(queuedEvents, attempts) + local attemptIndex = 0; + return function() + local handlers = {}; + local timeoutFn = nil; + local stopped = false; + local el = {}; + + function el.register(name, fn) + handlers[name] = fn; + return function() end; + end + function el.setTimeout(fn) + timeoutFn = fn; + return function() end; + end + function el.stopLoop() + stopped = true; + end + + function el.runLoop() + attemptIndex = attemptIndex + 1; + local steps = attempts[attemptIndex] or {}; + local token = latestVerifyToken(queuedEvents); + for _, step in ipairs(steps) do + if stopped then break; end + if step == 'connected' then + if handlers.trapos_cloud_connected then handlers.trapos_cloud_connected('7:base', token); end + elseif step == 'connected-mismatch' then + if handlers.trapos_cloud_connected then handlers.trapos_cloud_connected('7:base', 'other-token'); end + elseif step == 'connected-tokenless' then + if handlers.trapos_cloud_connected then handlers.trapos_cloud_connected('7:base', nil); end + elseif step == 'unauthorized' then + if handlers.trapos_cloud_unauthorized then handlers.trapos_cloud_unauthorized('7:base', 'unauthorized', token); end + elseif step == 'unauthorized-mismatch' then + if handlers.trapos_cloud_unauthorized then handlers.trapos_cloud_unauthorized('7:base', 'unauthorized', 'other-token'); end + elseif step == 'timeout' then + if timeoutFn then timeoutFn(); end + elseif step == 'terminate' then + if handlers.terminate then handlers.terminate(); end + break; -- the real default loop breaks on terminate + end + end + end + + el.startLoop = el.runLoop; + return el; + end +end + local function runCloud(args, opts) opts = opts or {}; local lines = {}; @@ -42,6 +115,8 @@ local function runCloud(args, opts) local queuedEvents = {}; local settingsLib = opts.settings or fakeSettings(); local readValues = opts.readValues or {}; + local fakeCreateEventLoop = makeFakeEventloop(queuedEvents, opts.attempts or {}); + local uuidCounter = 0; local env = setmetatable({ print = function(line) @@ -59,6 +134,7 @@ local function runCloud(args, opts) settings = { get = function(key) return settingsLib:get(key); end, set = function(key, value) return settingsLib:set(key, value); end, + unset = function(key) return settingsLib:unset(key); end, save = function() return settingsLib:save(); end, }, os = setmetatable({ @@ -78,9 +154,16 @@ local function runCloud(args, opts) }; return opts.requestOk ~= false, opts.requestResult or { serverOk = true, opencodeOk = true }; end, + uuid = function() + uuidCounter = uuidCounter + 1; + return 'tok-' .. uuidCounter; + end, }; end; end + if path == '/apis/eventloop' then + return fakeCreateEventLoop; + end if path == '/apis/libversion' then return function() return { forSelf = function() return '0.0.0-test'; end }; @@ -93,9 +176,10 @@ local function runCloud(args, opts) local chunk, loadErr = loadfile('/programs/cloud.lua', 't', env); if not chunk then error(loadErr, 0); end local ok, err = pcall(chunk, table.unpack(args or {})); - if not ok then error(err, 0); end return { + ok = ok, + err = err, lines = lines, writes = writes, readMasks = readMasks, @@ -105,6 +189,17 @@ local function runCloud(args, opts) }; end +-- Count queued events of a given name, optionally with a given reason as the first arg. +local function countQueued(queuedEvents, name, reason) + local n = 0; + for _, e in ipairs(queuedEvents) do + if e[1] == name and (reason == nil or e[2] == reason) then + n = n + 1; + end + end + return n; +end + testlib.test('cloud help lists status and login without health', function() local ctx = runCloud({ '--help' }); @@ -144,36 +239,46 @@ testlib.test('cloud set-password is not kept as an alias', function() testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:')); end); -testlib.test('cloud login stores a missing password with masked input', function() - local settingsLib = fakeSettings(); +testlib.test('cloud login verifies then saves a prompted password', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); local ctx = runCloud({ 'login' }, { settings = settingsLib, readValues = { 'hunter2' }, + attempts = { { 'connected' } }, }); + testlib.assertEquals(ctx.ok, true); testlib.assertEquals(ctx.writes[1], 'Password: '); testlib.assertEquals(ctx.readMasks[1], '*'); testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2'); testlib.assertEquals(settingsLib.saveCount, 1); - testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect'); - testlib.assertTrue(containsLine(ctx.lines, 'cloud.password set')); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 0); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-saved'), 0); + testlib.assertTrue(containsLine(ctx.lines, 'logged in')); + -- the verify reconnect carries the candidate secret, never persisted before acceptance + local verify = ctx.queuedEvents[1]; + testlib.assertEquals(verify[2], 'login-verify'); + testlib.assertEquals(verify[3], 'hunter2'); end); -testlib.test('cloud login stores a password argument without prompting', function() - local settingsLib = fakeSettings(); +testlib.test('cloud login verifies then saves a password argument without prompting', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); local ctx = runCloud({ 'login', 'hunter2' }, { settings = settingsLib, - readValues = { 'unused' }, + attempts = { { 'connected' } }, }); + testlib.assertEquals(ctx.ok, true); testlib.assertEquals(#ctx.readMasks, 0); testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2'); testlib.assertEquals(settingsLib.saveCount, 1); - testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect'); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1); + testlib.assertTrue(containsLine(ctx.lines, 'logged in')); end); -testlib.test('cloud login rejects replacing without force', function() - local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); +testlib.test('cloud login refuses replacing without force, before checking cloud.url', function() + local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); -- note: no cloud.url local ctx = runCloud({ 'login' }, { settings = settingsLib, readValues = { 'new' }, @@ -184,43 +289,189 @@ testlib.test('cloud login rejects replacing without force', function() testlib.assertEquals(settingsLib.saveCount, 0); testlib.assertEquals(#ctx.queuedEvents, 0); testlib.assertTrue(containsText(ctx.lines, 'use cloud login --force')); + testlib.assertTrue(not containsLine(ctx.lines, 'set cloud.url first')); end); -testlib.test('cloud login --force replaces an existing password', function() - local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); +testlib.test('cloud login --force verifies then replaces an existing password', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' }); local ctx = runCloud({ 'login', '--force' }, { settings = settingsLib, readValues = { 'new' }, + attempts = { { 'connected' } }, }); + testlib.assertEquals(ctx.ok, true); testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); testlib.assertEquals(settingsLib.saveCount, 1); - testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect'); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1); end); -testlib.test('cloud login -f replaces an existing password from argument', function() - local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); +testlib.test('cloud login -f verifies then replaces from a password argument', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' }); local ctx = runCloud({ 'login', '-f', 'new' }, { settings = settingsLib, + attempts = { { 'connected' } }, }); + testlib.assertEquals(ctx.ok, true); testlib.assertEquals(#ctx.readMasks, 0); testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); testlib.assertEquals(settingsLib.saveCount, 1); - testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect'); end); -testlib.test('cloud login rejects an empty password', function() +testlib.test('cloud login without cloud.url fails fast without prompting or saving', function() local settingsLib = fakeSettings(); local ctx = runCloud({ 'login' }, { settings = settingsLib, - readValues = { '' }, + readValues = { 'hunter2' }, }); + testlib.assertEquals(#ctx.readMasks, 0); testlib.assertEquals(settingsLib.values['cloud.password'], nil); testlib.assertEquals(settingsLib.saveCount, 0); testlib.assertEquals(#ctx.queuedEvents, 0); - testlib.assertTrue(containsLine(ctx.lines, 'cloud.password not changed: password cannot be empty')); + testlib.assertTrue(containsLine(ctx.lines, 'set cloud.url first')); +end); + +testlib.test('cloud login re-prompts after a wrong password and saves only the accepted one', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login' }, { + settings = settingsLib, + readValues = { 'wrong', 'right' }, + attempts = { { 'unauthorized' }, { 'connected' } }, + }); + + testlib.assertEquals(ctx.ok, true); + testlib.assertEquals(settingsLib.values['cloud.password'], 'right'); + testlib.assertEquals(settingsLib.saveCount, 1); + testlib.assertEquals(#ctx.readMasks, 2); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 2); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1); + testlib.assertTrue(containsText(ctx.lines, 'invalid password, try again')); +end); + +testlib.test('cloud login re-prompts on a blank password without verifying it', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login' }, { + settings = settingsLib, + readValues = { '', 'good' }, + attempts = { { 'connected' } }, + }); + + testlib.assertEquals(ctx.ok, true); + testlib.assertEquals(settingsLib.values['cloud.password'], 'good'); + testlib.assertEquals(#ctx.readMasks, 2); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1); + testlib.assertEquals(ctx.queuedEvents[1][3], 'good'); -- never the blank value + testlib.assertTrue(containsLine(ctx.lines, 'password cannot be empty')); +end); + +testlib.test('cloud login interactive timeout restores and does not save', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login' }, { + settings = settingsLib, + readValues = { 'hunter2' }, + attempts = { { 'timeout' } }, + }); + + testlib.assertEquals(ctx.ok, true); + testlib.assertEquals(settingsLib.values['cloud.password'], nil); + testlib.assertEquals(settingsLib.saveCount, 0); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1); + testlib.assertTrue(containsLine(ctx.lines, 'cloud login failed: could not reach gateway')); +end); + +testlib.test('cloud login interactive terminate restores and raises Terminated', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login' }, { + settings = settingsLib, + readValues = { 'hunter2' }, + attempts = { { 'terminate' } }, + }); + + testlib.assertEquals(ctx.ok, false); + testlib.assertTrue(string.find(tostring(ctx.err), 'Terminated', 1, true) ~= nil); + testlib.assertEquals(settingsLib.values['cloud.password'], nil); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1); +end); + +testlib.test('cloud login non-interactive blank password errors without restore or save', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login', '' }, { settings = settingsLib }); + + testlib.assertEquals(ctx.ok, false); + testlib.assertTrue(string.find(tostring(ctx.err), 'password cannot be empty', 1, true) ~= nil); + testlib.assertEquals(settingsLib.values['cloud.password'], nil); + testlib.assertEquals(settingsLib.saveCount, 0); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 0); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 0); +end); + +testlib.test('cloud login non-interactive wrong password restores and errors', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login', 'wrong' }, { + settings = settingsLib, + attempts = { { 'unauthorized' } }, + }); + + testlib.assertEquals(ctx.ok, false); + testlib.assertTrue(string.find(tostring(ctx.err), 'invalid password', 1, true) ~= nil); + testlib.assertEquals(settingsLib.values['cloud.password'], nil); + testlib.assertEquals(settingsLib.saveCount, 0); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1); +end); + +testlib.test('cloud login non-interactive timeout restores and errors', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login', 'hunter2' }, { + settings = settingsLib, + attempts = { { 'timeout' } }, + }); + + testlib.assertEquals(ctx.ok, false); + testlib.assertTrue(string.find(tostring(ctx.err), 'could not reach gateway', 1, true) ~= nil); + testlib.assertEquals(settingsLib.saveCount, 0); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1); +end); + +testlib.test('cloud login ignores tokenless and mismatched verdicts until its own token matches', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); + local ctx = runCloud({ 'login', 'hunter2' }, { + settings = settingsLib, + attempts = { { 'connected-tokenless', 'unauthorized-mismatch', 'connected' } }, + }); + + testlib.assertEquals(ctx.ok, true); + testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2'); + testlib.assertEquals(settingsLib.saveCount, 1); + testlib.assertTrue(containsLine(ctx.lines, 'logged in')); +end); + +testlib.test('cloud login save failure restores the old password in memory and errors', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' }, false); + local ctx = runCloud({ 'login', '--force', 'new' }, { + settings = settingsLib, + attempts = { { 'connected' } }, + }); + + testlib.assertEquals(ctx.ok, false); + testlib.assertTrue(string.find(tostring(ctx.err), 'could not save settings', 1, true) ~= nil); + testlib.assertEquals(settingsLib.values['cloud.password'], 'old'); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1); + testlib.assertTrue(not containsLine(ctx.lines, 'logged in')); +end); + +testlib.test('cloud login save failure with no prior password unsets it and errors', function() + local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }, false); + local ctx = runCloud({ 'login', 'new' }, { + settings = settingsLib, + attempts = { { 'connected' } }, + }); + + testlib.assertEquals(ctx.ok, false); + testlib.assertTrue(string.find(tostring(ctx.err), 'could not save settings', 1, true) ~= nil); + testlib.assertEquals(settingsLib.values['cloud.password'], nil); + testlib.assertEquals(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-restore'), 1); end); testlib.run(); diff --git a/packages/trapos-cloud/tests/cloud.lua b/packages/trapos-cloud/tests/cloud.lua index ad036f7..afb82c9 100644 --- a/packages/trapos-cloud/tests/cloud.lua +++ b/packages/trapos-cloud/tests/cloud.lua @@ -327,6 +327,81 @@ testlib.test('startSession reconnect event recovers after unauthorized', functio testlib.assertEquals(hello2.payload.secret, 'new'); end); +-- login verification: candidate secret + correlation token ---------------- + +testlib.test('a verify reconnect sends the candidate secret in the next hello', function() + local ctx = startSession({ getSecret = function() return 'saved'; end }); + ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok-1'); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + testlib.assertEquals(textutils.unserializeJSON(ws.sent[1]).payload.secret, 'candidate'); +end); + +testlib.test('a verified hello echoes the token, clears it, and keeps the candidate secret', function() + local ctx = startSession({ getSecret = function() return 'saved'; end }); + ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok-9'); + local ws1 = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws1); + + ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} })); + local connected = ctx.queued[#ctx.queued]; + testlib.assertEquals(connected[1], 'trapos_cloud_connected'); + testlib.assertEquals(connected[2], '7:base'); + testlib.assertEquals(connected[3], 'tok-9'); + + -- A later natural reconnect still uses the retained candidate, but emits no token. + ctx.el.fire('websocket_closed', ctx.url); + ctx.el.fireTimer(#ctx.el.timers); + local ws2 = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws2); + testlib.assertEquals(textutils.unserializeJSON(ws2.sent[1]).payload.secret, 'candidate'); + ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} })); + local connected2 = ctx.queued[#ctx.queued]; + testlib.assertEquals(connected2[1], 'trapos_cloud_connected'); + testlib.assertEquals(connected2[3], nil); +end); + +testlib.test('a rejected hello emits trapos_cloud_unauthorized with the verify token', function() + local ctx = startSession(); + ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok-bad'); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + + ctx.el.fire('websocket_message', ctx.url, encode({ + event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad' }, + })); + + local evt = ctx.queued[#ctx.queued]; + testlib.assertEquals(evt[1], 'trapos_cloud_unauthorized'); + testlib.assertEquals(evt[2], '7:base'); + testlib.assertEquals(evt[3], 'unauthorized'); + testlib.assertEquals(evt[4], 'tok-bad'); + testlib.assertEquals(ctx.session.lastError(), 'unauthorized'); +end); + +testlib.test('a plain reconnect clears the candidate override and falls back to the saved secret', function() + local ctx = startSession({ getSecret = function() return 'saved'; end }); + ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok'); + local ws1 = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws1); + testlib.assertEquals(textutils.unserializeJSON(ws1.sent[1]).payload.secret, 'candidate'); + + ctx.el.fire('trapos_cloud_reconnect', 'login-restore'); + local ws2 = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws2); + testlib.assertEquals(textutils.unserializeJSON(ws2.sent[1]).payload.secret, 'saved'); +end); + +testlib.test('the candidate override survives a websocket failure retry', function() + local ctx = startSession({ getSecret = function() return 'saved'; end }); + ctx.el.fire('trapos_cloud_reconnect', 'login-verify', 'candidate', 'tok'); + ctx.el.fire('websocket_failure', ctx.url); -- first attempt fails before hello + ctx.el.fireTimer(#ctx.el.timers); -- retry connect() + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + testlib.assertEquals(textutils.unserializeJSON(ws.sent[1]).payload.secret, 'candidate'); +end); + testlib.test('startSession logs the reconnect loop once on entry and once on recovery', function() local logs = {}; local function countMsg(msg) diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 449f875..fb60333 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.12.2", + "version": "0.12.3", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot",