feat(cloud): verify login password

This commit is contained in:
Guillaume ARM 2026-06-17 23:21:12 +02:00
parent 674a0bb15a
commit 1d4789be46
8 changed files with 481 additions and 39 deletions

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.12.2",
"version": "0.12.3",
"branch": "master",
"packages": [
"trapos"

View File

@ -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"
}
}

View File

@ -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);

View File

@ -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": [

View File

@ -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
-- 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);
settings.save();
os.queueEvent(RECONNECT_EVENT);
print('cloud.password set');
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();

View File

@ -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();

View File

@ -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)

View File

@ -1,6 +1,6 @@
{
"name": "trapos",
"version": "0.12.2",
"version": "0.12.3",
"description": "TrapOS full install meta-package",
"dependencies": [
"trapos-boot",