local createLibTest = require('/apis/libtest'); local testlib = createLibTest({ ... }); local function containsLine(lines, expected) for _, line in ipairs(lines) do if line == expected then return true; end end return false; end local function containsText(lines, expected) for _, line in ipairs(lines) do if string.find(line, expected, 1, true) then return true; end end return false; end 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 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 = {}; local writes = {}; local readMasks = {}; local cloudRequests = {}; 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) lines[#lines + 1] = line or ''; end, write = function(text) writes[#writes + 1] = text or ''; end, read = function(mask) readMasks[#readMasks + 1] = mask; local value = readValues[#readMasks]; if value == nil then return ''; end return value; end, 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({ queueEvent = function(...) queuedEvents[#queuedEvents + 1] = table.pack(...); end, }, { __index = os }), require = function(path) if path == '/apis/libcloud' then return function() return { request = function(msgType, payload, requestOpts) cloudRequests[#cloudRequests + 1] = { msgType = msgType, payload = payload, opts = requestOpts, }; 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 }; end; end return require(path); end, }, { __index = _G }); 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 {})); return { ok = ok, err = err, lines = lines, writes = writes, readMasks = readMasks, cloudRequests = cloudRequests, queuedEvents = queuedEvents, settings = settingsLib, }; 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' }); testlib.assertTrue(containsLine(ctx.lines, ' cloud status')); testlib.assertTrue(containsLine(ctx.lines, ' cloud login [--force|-f] [password]')); testlib.assertTrue(not containsLine(ctx.lines, ' cloud health')); testlib.assertTrue(not containsText(ctx.lines, 'set-password')); end); testlib.test('cloud health is not kept as an alias', function() local ctx = runCloud({ 'health' }, { settings = fakeSettings({ ['cloud.url'] = 'wss://example.test' }), }); testlib.assertEquals(#ctx.cloudRequests, 0); testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:')); end); testlib.test('cloud status probes the server', function() local ctx = runCloud({ 'status' }, { settings = fakeSettings({ ['cloud.url'] = 'wss://example.test' }), requestResult = { serverOk = true, opencodeOk = false, opencodeDetail = 'missing key' }, }); testlib.assertEquals(#ctx.cloudRequests, 1); testlib.assertEquals(ctx.cloudRequests[1].msgType, 'probe_server'); testlib.assertTrue(containsLine(ctx.lines, 'serverOk: true')); testlib.assertTrue(containsLine(ctx.lines, 'opencodeOk: false')); testlib.assertTrue(containsLine(ctx.lines, 'opencode: missing key')); end); testlib.test('cloud set-password is not kept as an alias', function() local ctx = runCloud({ 'set-password', 'hunter2' }); testlib.assertEquals(ctx.settings.values['cloud.password'], nil); testlib.assertEquals(ctx.settings.saveCount, 0); testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:')); end); 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(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 verifies then saves a password argument without prompting', function() local settingsLib = fakeSettings({ ['cloud.url'] = 'wss://example.test' }); local ctx = runCloud({ 'login', 'hunter2' }, { settings = settingsLib, 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(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1); testlib.assertTrue(containsLine(ctx.lines, 'logged in')); end); 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' }, }); testlib.assertEquals(#ctx.readMasks, 0); testlib.assertEquals(settingsLib.values['cloud.password'], 'old'); 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 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(countQueued(ctx.queuedEvents, RECONNECT_EVENT, 'login-verify'), 1); end); 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); end); testlib.test('cloud login without cloud.url fails fast without prompting or saving', function() local settingsLib = fakeSettings(); local ctx = runCloud({ 'login' }, { settings = settingsLib, 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, '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();