pending.nvim/spec/s3_spec.lua
Barrett Ruth 149f2dac2e fix(sync): auto-trigger auth flow on unauthenticated sync actions (#120)
Problem: running a sync action (e.g. `:Pending gtasks push`) without
being authenticated would silently abort with a warning, requiring
the user to manually run `:Pending auth` first.

Solution: `oauth.with_token()` now auto-triggers the browser auth flow
when no token exists (for non-bundled credentials) and resumes the
original action on success. `auth()` and `_exchange_code()` now call
`on_complete(ok)` on all exit paths. S3 backends run
`aws sts get-caller-identity` before every sync action, auto-triggering
SSO login on expired sessions.
2026-03-10 11:36:31 -04:00

626 lines
19 KiB
Lua

require('spec.helpers')
local config = require('pending.config')
local util = require('pending.sync.util')
describe('s3', function()
local tmpdir
local pending
local s3
local orig_system
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { s3 = { bucket = 'test-bucket', key = 'test.json' } },
}
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
pending = require('pending')
s3 = require('pending.sync.s3')
orig_system = util.system
end)
after_each(function()
util.system = orig_system
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
end)
it('has correct name', function()
assert.equals('s3', s3.name)
end)
it('has auth function', function()
assert.equals('function', type(s3.auth))
end)
it('has auth_complete returning profile', function()
local completions = s3.auth_complete()
assert.is_true(vim.tbl_contains(completions, 'profile'))
end)
it('has push, pull, sync functions', function()
assert.equals('function', type(s3.push))
assert.equals('function', type(s3.pull))
assert.equals('function', type(s3.sync))
end)
it('has health function', function()
assert.equals('function', type(s3.health))
end)
describe('ensure_sync_id', function()
it('assigns a UUID-like sync id', function()
local task = { _extra = nil, modified = '2026-01-01T00:00:00Z' }
local id = s3._ensure_sync_id(task)
assert.is_not_nil(id)
assert.truthy(
id:match('^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$')
)
assert.equals(id, task._extra['_s3_sync_id'])
end)
it('returns existing sync id without regenerating', function()
local task = {
_extra = { _s3_sync_id = 'existing-id' },
modified = '2026-01-01T00:00:00Z',
}
local id = s3._ensure_sync_id(task)
assert.equals('existing-id', id)
end)
end)
describe('auth', function()
it('reports success on valid credentials', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('authenticated'))
end)
it('skips bucket creation when bucket is configured', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
return { code = 0, stdout = '', stderr = '' }
end
local orig_input = util.input
local input_called = false
util.input = function()
input_called = true
return nil
end
s3.auth()
util.input = orig_input
assert.is_false(input_called)
end)
it('detects SSO expiry', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 1, stdout = '', stderr = 'Error: SSO session expired' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('SSO'))
end)
it('detects missing credentials', function()
util.system = function()
return { code = 1, stdout = '', stderr = 'Unable to locate credentials' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('no AWS credentials'))
end)
end)
describe('auth bucket creation', function()
local orig_input
before_each(function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
pending = require('pending')
s3 = require('pending.sync.s3')
orig_input = util.input
end)
after_each(function()
util.input = orig_input
end)
it('prompts for bucket when none configured', function()
local input_calls = {}
util.input = function(opts)
table.insert(input_calls, opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'us-west-2\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.equals(2, #input_calls)
assert.is_not_nil(create_args)
assert.truthy(vim.tbl_contains(create_args, 'my-bucket'))
assert.truthy(msg and msg:find('bucket created'))
end)
it('cancels when user provides nil bucket name', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return nil
end
return ''
end
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m)
msg = m
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('cancelled'))
end)
it('omits LocationConstraint for us-east-1', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
if opts.prompt:find('region') then
return 'us-east-1'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.auth()
assert.is_not_nil(create_args)
local joined = table.concat(create_args, ' ')
assert.falsy(joined:find('LocationConstraint'))
end)
it('includes LocationConstraint for non-us-east-1 regions', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
if opts.prompt:find('region') then
return 'eu-west-1'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'eu-west-1\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.auth()
assert.is_not_nil(create_args)
assert.truthy(vim.tbl_contains(create_args, 'LocationConstraint=eu-west-1'))
end)
it('reports error on bucket creation failure', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'bad-bucket'
end
return ''
end
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
return { code = 1, stdout = '', stderr = 'BucketAlreadyExists' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
s3.auth()
vim.notify = orig_notify
assert.truthy(msg and msg:find('bucket creation failed'))
end)
it('defaults region to us-east-1 when aws configure returns nothing', function()
util.input = function(opts)
if opts.prompt:find('bucket') then
return 'my-bucket'
end
return ''
end
local create_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return {
code = 0,
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
stderr = '',
}
end
if vim.tbl_contains(args, 'configure') then
return { code = 1, stdout = '', stderr = '' }
end
if vim.tbl_contains(args, 'create-bucket') then
create_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.auth()
assert.is_not_nil(create_args)
assert.truthy(vim.tbl_contains(create_args, 'us-east-1'))
local joined = table.concat(create_args, ' ')
assert.falsy(joined:find('LocationConstraint'))
end)
end)
describe('ensure_credentials', function()
it('returns true on valid credentials', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 0, stdout = '{"Account":"123"}', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
assert.is_true(s3._ensure_credentials())
end)
it('returns false on missing credentials', function()
util.system = function()
return { code = 1, stdout = '', stderr = 'Unable to locate credentials' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
assert.is_false(s3._ensure_credentials())
vim.notify = orig_notify
assert.truthy(msg and msg:find('no AWS credentials'))
end)
it('retries SSO login on expired session', function()
local calls = {}
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 1, stdout = '', stderr = 'Error: SSO session expired' }
end
if vim.tbl_contains(args, 'sso') then
table.insert(calls, 'sso-login')
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
assert.is_true(s3._ensure_credentials())
assert.equals(1, #calls)
assert.equals('sso-login', calls[1])
end)
it('returns false when SSO login fails', function()
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 1, stdout = '', stderr = 'SSO token expired' }
end
if vim.tbl_contains(args, 'sso') then
return { code = 1, stdout = '', stderr = 'login failed' }
end
return { code = 0, stdout = '', stderr = '' }
end
assert.is_false(s3._ensure_credentials())
end)
end)
describe('push', function()
it('uploads store to S3', function()
local s = pending.store()
s:load()
s:add({ description = 'Test task', status = 'pending', category = 'Work', priority = 0 })
s:save()
local captured_args
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 0, stdout = '{"Account":"123"}', stderr = '' }
end
if vim.tbl_contains(args, 's3') then
captured_args = args
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.push()
assert.is_not_nil(captured_args)
local joined = table.concat(captured_args, ' ')
assert.truthy(joined:find('s3://test%-bucket/test%.json'))
end)
it('errors when bucket is not configured', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.sync.s3'] = nil
pending = require('pending')
s3 = require('pending.sync.s3')
util.system = function(args)
if vim.tbl_contains(args, 'get-caller-identity') then
return { code = 0, stdout = '{"Account":"123"}', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
local msg
local orig_notify = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
s3.push()
vim.notify = orig_notify
assert.truthy(msg and msg:find('bucket is required'))
end)
end)
describe('pull merge', function()
it('merges remote tasks by sync_id', function()
local store_mod = require('pending.store')
local s = pending.store()
s:load()
local local_task = s:add({
description = 'Local task',
status = 'pending',
category = 'Work',
priority = 0,
})
local_task._extra = { _s3_sync_id = 'sync-1' }
local_task.modified = '2026-03-01T00:00:00Z'
s:save()
local remote_path = tmpdir .. '/remote.json'
local remote_store = store_mod.new(remote_path)
remote_store:load()
local remote_task = remote_store:add({
description = 'Updated remotely',
status = 'pending',
category = 'Work',
priority = 1,
})
remote_task._extra = { _s3_sync_id = 'sync-1' }
remote_task.modified = '2026-03-05T00:00:00Z'
local new_remote = remote_store:add({
description = 'New remote task',
status = 'pending',
category = 'Personal',
priority = 0,
})
new_remote._extra = { _s3_sync_id = 'sync-2' }
new_remote.modified = '2026-03-04T00:00:00Z'
remote_store:save()
util.system = function(args)
if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then
for i, arg in ipairs(args) do
if arg:match('^s3://') then
local dest = args[i + 1]
if dest and not dest:match('^s3://') then
local src = io.open(remote_path, 'r')
local content = src:read('*a')
src:close()
local f = io.open(dest, 'w')
f:write(content)
f:close()
end
break
end
end
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.pull()
s:load()
local tasks = s:tasks()
assert.equals(2, #tasks)
local found_updated = false
local found_new = false
for _, t in ipairs(tasks) do
if t._extra and t._extra['_s3_sync_id'] == 'sync-1' then
assert.equals('Updated remotely', t.description)
assert.equals(1, t.priority)
found_updated = true
end
if t._extra and t._extra['_s3_sync_id'] == 'sync-2' then
assert.equals('New remote task', t.description)
found_new = true
end
end
assert.is_true(found_updated)
assert.is_true(found_new)
end)
it('keeps local version when local is newer', function()
local s = pending.store()
s:load()
local local_task = s:add({
description = 'Local version',
status = 'pending',
category = 'Work',
priority = 0,
})
local_task._extra = { _s3_sync_id = 'sync-3' }
local_task.modified = '2026-03-10T00:00:00Z'
s:save()
local store_mod = require('pending.store')
local remote_path = tmpdir .. '/remote2.json'
local remote_store = store_mod.new(remote_path)
remote_store:load()
local remote_task = remote_store:add({
description = 'Older remote',
status = 'pending',
category = 'Work',
priority = 0,
})
remote_task._extra = { _s3_sync_id = 'sync-3' }
remote_task.modified = '2026-03-05T00:00:00Z'
remote_store:save()
util.system = function(args)
if vim.tbl_contains(args, 's3') and vim.tbl_contains(args, 'cp') then
for i, arg in ipairs(args) do
if arg:match('^s3://') then
local dest = args[i + 1]
if dest and not dest:match('^s3://') then
local src = io.open(remote_path, 'r')
local content = src:read('*a')
src:close()
local f = io.open(dest, 'w')
f:write(content)
f:close()
end
break
end
end
return { code = 0, stdout = '', stderr = '' }
end
return { code = 0, stdout = '', stderr = '' }
end
s3.pull()
s:load()
local tasks = s:tasks()
assert.equals(1, #tasks)
assert.equals('Local version', tasks[1].description)
end)
end)
end)