feat(cloud): verify login password
This commit is contained in:
parent
674a0bb15a
commit
1d4789be46
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.12.2",
|
"version": "0.12.3",
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
"trapos-net": "0.3.2",
|
"trapos-net": "0.3.2",
|
||||||
"trapos-ui": "0.2.4",
|
"trapos-ui": "0.2.4",
|
||||||
"trapos-ai": "0.8.0",
|
"trapos-ai": "0.8.0",
|
||||||
"trapos-cloud": "0.4.2",
|
"trapos-cloud": "0.4.3",
|
||||||
"trapos-sandbox-legacy": "0.3.2",
|
"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.)
|
-- (unauthorized and displaced are terminal: no reconnect.)
|
||||||
local state = 'connecting';
|
local state = 'connecting';
|
||||||
local lastError = nil;
|
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 activeWs = nil;
|
||||||
local suppressNextClose = false;
|
local suppressNextClose = false;
|
||||||
local clearReconnectTimeout = nil;
|
local clearReconnectTimeout = nil;
|
||||||
@ -241,7 +248,7 @@ local function createCloud()
|
|||||||
clearReconnectTimeout();
|
clearReconnectTimeout();
|
||||||
clearReconnectTimeout = nil;
|
clearReconnectTimeout = nil;
|
||||||
end
|
end
|
||||||
secret = getSecret();
|
secret = secretOverride or getSecret();
|
||||||
state = 'connecting';
|
state = 'connecting';
|
||||||
if not reconnecting then
|
if not reconnecting then
|
||||||
log('info', 'connecting', { url = url });
|
log('info', 'connecting', { url = url });
|
||||||
@ -287,11 +294,17 @@ local function createCloud()
|
|||||||
else
|
else
|
||||||
log('info', 'connected', { traposId = traposId });
|
log('info', 'connected', { traposId = traposId });
|
||||||
end
|
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
|
else
|
||||||
state = 'unauthorized';
|
state = 'unauthorized';
|
||||||
lastError = (type(err) == 'table' and err.code) or 'unauthorized';
|
lastError = (type(err) == 'table' and err.code) or 'unauthorized';
|
||||||
log('warn', 'hello rejected; yielding', { code = lastError });
|
log('warn', 'hello rejected; yielding', { code = lastError });
|
||||||
|
queueEvent('trapos_cloud_unauthorized', traposId, lastError, verifyToken);
|
||||||
closeActive();
|
closeActive();
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -399,7 +412,16 @@ local function createCloud()
|
|||||||
sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err));
|
sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err));
|
||||||
end);
|
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);
|
reconnectNow(reason);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-cloud",
|
"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",
|
"description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
local createCloud = require('/apis/libcloud');
|
local createCloud = require('/apis/libcloud');
|
||||||
local createVersion = require('/apis/libversion');
|
local createVersion = require('/apis/libversion');
|
||||||
|
local createEventLoop = require('/apis/eventloop');
|
||||||
|
|
||||||
local URL_SETTING = 'cloud.url';
|
local URL_SETTING = 'cloud.url';
|
||||||
local PASSWORD_SETTING = 'cloud.password';
|
local PASSWORD_SETTING = 'cloud.password';
|
||||||
local RECONNECT_EVENT = 'trapos_cloud_reconnect';
|
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 rawArgs = table.pack(...);
|
||||||
local command = rawArgs[1];
|
local command = rawArgs[1];
|
||||||
@ -70,7 +74,7 @@ if command == 'status' then
|
|||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
local ok, result = createCloud().request('probe_server');
|
local ok, result = cloudApi.request('probe_server');
|
||||||
if not ok then
|
if not ok then
|
||||||
local code = (type(result) == 'table' and result.code) or 'internal';
|
local code = (type(result) == 'table' and result.code) or 'internal';
|
||||||
local message = (type(result) == 'table' and result.message) or '';
|
local message = (type(result) == 'table' and result.message) or '';
|
||||||
@ -99,17 +103,107 @@ if command == 'login' then
|
|||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
local password = opts.password or readPassword();
|
if isBlank(settings.get(URL_SETTING)) then
|
||||||
if isBlank(password) then
|
print('set cloud.url first');
|
||||||
print('cloud.password not changed: password cannot be empty');
|
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
settings.set(PASSWORD_SETTING, password);
|
-- Ask the daemon to reconnect with `candidate` (never persisted) and wait for the handshake
|
||||||
settings.save();
|
-- verdict carrying our token. Returns 'ok' | 'unauthorized' | 'timeout' | 'terminate'. Other
|
||||||
os.queueEvent(RECONNECT_EVENT);
|
-- daemon verdicts (tokenless or for a different token) are ignored.
|
||||||
print('cloud.password set');
|
local function verifyPassword(candidate)
|
||||||
return;
|
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
|
end
|
||||||
|
|
||||||
printUsage();
|
printUsage();
|
||||||
|
|||||||
@ -16,23 +16,96 @@ local function containsText(lines, expected)
|
|||||||
return false;
|
return false;
|
||||||
end
|
end
|
||||||
|
|
||||||
local function fakeSettings(values)
|
local RECONNECT_EVENT = 'trapos_cloud_reconnect';
|
||||||
|
|
||||||
|
local function fakeSettings(values, saveOk)
|
||||||
return {
|
return {
|
||||||
values = values or {},
|
values = values or {},
|
||||||
saveCount = 0,
|
saveCount = 0,
|
||||||
|
saveOk = saveOk ~= false,
|
||||||
get = function(self, key)
|
get = function(self, key)
|
||||||
return self.values[key];
|
return self.values[key];
|
||||||
end,
|
end,
|
||||||
set = function(self, key, value)
|
set = function(self, key, value)
|
||||||
self.values[key] = value;
|
self.values[key] = value;
|
||||||
end,
|
end,
|
||||||
|
unset = function(self, key)
|
||||||
|
self.values[key] = nil;
|
||||||
|
end,
|
||||||
save = function(self)
|
save = function(self)
|
||||||
self.saveCount = self.saveCount + 1;
|
self.saveCount = self.saveCount + 1;
|
||||||
return true;
|
return self.saveOk;
|
||||||
end,
|
end,
|
||||||
};
|
};
|
||||||
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)
|
local function runCloud(args, opts)
|
||||||
opts = opts or {};
|
opts = opts or {};
|
||||||
local lines = {};
|
local lines = {};
|
||||||
@ -42,6 +115,8 @@ local function runCloud(args, opts)
|
|||||||
local queuedEvents = {};
|
local queuedEvents = {};
|
||||||
local settingsLib = opts.settings or fakeSettings();
|
local settingsLib = opts.settings or fakeSettings();
|
||||||
local readValues = opts.readValues or {};
|
local readValues = opts.readValues or {};
|
||||||
|
local fakeCreateEventLoop = makeFakeEventloop(queuedEvents, opts.attempts or {});
|
||||||
|
local uuidCounter = 0;
|
||||||
|
|
||||||
local env = setmetatable({
|
local env = setmetatable({
|
||||||
print = function(line)
|
print = function(line)
|
||||||
@ -59,6 +134,7 @@ local function runCloud(args, opts)
|
|||||||
settings = {
|
settings = {
|
||||||
get = function(key) return settingsLib:get(key); end,
|
get = function(key) return settingsLib:get(key); end,
|
||||||
set = function(key, value) return settingsLib:set(key, value); 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,
|
save = function() return settingsLib:save(); end,
|
||||||
},
|
},
|
||||||
os = setmetatable({
|
os = setmetatable({
|
||||||
@ -78,9 +154,16 @@ local function runCloud(args, opts)
|
|||||||
};
|
};
|
||||||
return opts.requestOk ~= false, opts.requestResult or { serverOk = true, opencodeOk = true };
|
return opts.requestOk ~= false, opts.requestResult or { serverOk = true, opencodeOk = true };
|
||||||
end,
|
end,
|
||||||
|
uuid = function()
|
||||||
|
uuidCounter = uuidCounter + 1;
|
||||||
|
return 'tok-' .. uuidCounter;
|
||||||
|
end,
|
||||||
};
|
};
|
||||||
end;
|
end;
|
||||||
end
|
end
|
||||||
|
if path == '/apis/eventloop' then
|
||||||
|
return fakeCreateEventLoop;
|
||||||
|
end
|
||||||
if path == '/apis/libversion' then
|
if path == '/apis/libversion' then
|
||||||
return function()
|
return function()
|
||||||
return { forSelf = function() return '0.0.0-test'; end };
|
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);
|
local chunk, loadErr = loadfile('/programs/cloud.lua', 't', env);
|
||||||
if not chunk then error(loadErr, 0); end
|
if not chunk then error(loadErr, 0); end
|
||||||
local ok, err = pcall(chunk, table.unpack(args or {}));
|
local ok, err = pcall(chunk, table.unpack(args or {}));
|
||||||
if not ok then error(err, 0); end
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ok = ok,
|
||||||
|
err = err,
|
||||||
lines = lines,
|
lines = lines,
|
||||||
writes = writes,
|
writes = writes,
|
||||||
readMasks = readMasks,
|
readMasks = readMasks,
|
||||||
@ -105,6 +189,17 @@ local function runCloud(args, opts)
|
|||||||
};
|
};
|
||||||
end
|
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()
|
testlib.test('cloud help lists status and login without health', function()
|
||||||
local ctx = runCloud({ '--help' });
|
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:'));
|
testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:'));
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('cloud login stores a missing password with masked input', function()
|
testlib.test('cloud login verifies then saves a prompted password', function()
|
||||||
local settingsLib = fakeSettings();
|
local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
|
||||||
local ctx = runCloud({ 'login' }, {
|
local ctx = runCloud({ 'login' }, {
|
||||||
settings = settingsLib,
|
settings = settingsLib,
|
||||||
readValues = { 'hunter2' },
|
readValues = { 'hunter2' },
|
||||||
|
attempts = { { 'connected' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(ctx.ok, true);
|
||||||
testlib.assertEquals(ctx.writes[1], 'Password: ');
|
testlib.assertEquals(ctx.writes[1], 'Password: ');
|
||||||
testlib.assertEquals(ctx.readMasks[1], '*');
|
testlib.assertEquals(ctx.readMasks[1], '*');
|
||||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
|
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
|
||||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
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, 'cloud.password set'));
|
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);
|
end);
|
||||||
|
|
||||||
testlib.test('cloud login stores a password argument without prompting', function()
|
testlib.test('cloud login verifies then saves a password argument without prompting', function()
|
||||||
local settingsLib = fakeSettings();
|
local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' });
|
||||||
local ctx = runCloud({ 'login', 'hunter2' }, {
|
local ctx = runCloud({ 'login', 'hunter2' }, {
|
||||||
settings = settingsLib,
|
settings = settingsLib,
|
||||||
readValues = { 'unused' },
|
attempts = { { 'connected' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(ctx.ok, true);
|
||||||
testlib.assertEquals(#ctx.readMasks, 0);
|
testlib.assertEquals(#ctx.readMasks, 0);
|
||||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
|
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
|
||||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
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);
|
end);
|
||||||
|
|
||||||
testlib.test('cloud login rejects replacing without force', function()
|
testlib.test('cloud login refuses replacing without force, before checking cloud.url', function()
|
||||||
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
|
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); -- note: no cloud.url
|
||||||
local ctx = runCloud({ 'login' }, {
|
local ctx = runCloud({ 'login' }, {
|
||||||
settings = settingsLib,
|
settings = settingsLib,
|
||||||
readValues = { 'new' },
|
readValues = { 'new' },
|
||||||
@ -184,43 +289,189 @@ testlib.test('cloud login rejects replacing without force', function()
|
|||||||
testlib.assertEquals(settingsLib.saveCount, 0);
|
testlib.assertEquals(settingsLib.saveCount, 0);
|
||||||
testlib.assertEquals(#ctx.queuedEvents, 0);
|
testlib.assertEquals(#ctx.queuedEvents, 0);
|
||||||
testlib.assertTrue(containsText(ctx.lines, 'use cloud login --force'));
|
testlib.assertTrue(containsText(ctx.lines, 'use cloud login --force'));
|
||||||
|
testlib.assertTrue(not containsLine(ctx.lines, 'set cloud.url first'));
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('cloud login --force replaces an existing password', function()
|
testlib.test('cloud login --force verifies then replaces an existing password', function()
|
||||||
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
|
local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' });
|
||||||
local ctx = runCloud({ 'login', '--force' }, {
|
local ctx = runCloud({ 'login', '--force' }, {
|
||||||
settings = settingsLib,
|
settings = settingsLib,
|
||||||
readValues = { 'new' },
|
readValues = { 'new' },
|
||||||
|
attempts = { { 'connected' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(ctx.ok, true);
|
||||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
|
testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
|
||||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
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);
|
end);
|
||||||
|
|
||||||
testlib.test('cloud login -f replaces an existing password from argument', function()
|
testlib.test('cloud login -f verifies then replaces from a password argument', function()
|
||||||
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
|
local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test', ['cloud.password'] = 'old' });
|
||||||
local ctx = runCloud({ 'login', '-f', 'new' }, {
|
local ctx = runCloud({ 'login', '-f', 'new' }, {
|
||||||
settings = settingsLib,
|
settings = settingsLib,
|
||||||
|
attempts = { { 'connected' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(ctx.ok, true);
|
||||||
testlib.assertEquals(#ctx.readMasks, 0);
|
testlib.assertEquals(#ctx.readMasks, 0);
|
||||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
|
testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
|
||||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
testlib.assertEquals(settingsLib.saveCount, 1);
|
||||||
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
|
|
||||||
end);
|
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 settingsLib = fakeSettings();
|
||||||
local ctx = runCloud({ 'login' }, {
|
local ctx = runCloud({ 'login' }, {
|
||||||
settings = settingsLib,
|
settings = settingsLib,
|
||||||
readValues = { '' },
|
readValues = { 'hunter2' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testlib.assertEquals(#ctx.readMasks, 0);
|
||||||
testlib.assertEquals(settingsLib.values['cloud.password'], nil);
|
testlib.assertEquals(settingsLib.values['cloud.password'], nil);
|
||||||
testlib.assertEquals(settingsLib.saveCount, 0);
|
testlib.assertEquals(settingsLib.saveCount, 0);
|
||||||
testlib.assertEquals(#ctx.queuedEvents, 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);
|
end);
|
||||||
|
|
||||||
testlib.run();
|
testlib.run();
|
||||||
|
|||||||
@ -327,6 +327,81 @@ testlib.test('startSession reconnect event recovers after unauthorized', functio
|
|||||||
testlib.assertEquals(hello2.payload.secret, 'new');
|
testlib.assertEquals(hello2.payload.secret, 'new');
|
||||||
end);
|
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()
|
testlib.test('startSession logs the reconnect loop once on entry and once on recovery', function()
|
||||||
local logs = {};
|
local logs = {};
|
||||||
local function countMsg(msg)
|
local function countMsg(msg)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.12.2",
|
"version": "0.12.3",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trapos-boot",
|
"trapos-boot",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user