feat(cloud): verify login password
This commit is contained in:
parent
674a0bb15a
commit
1d4789be46
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"branch": "master",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": [
|
||||
"trapos-boot",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user