feat: manage existing credential actions with audited replay

This commit is contained in:
2026-11-01 19:10:37 +08:00
parent baa81c4923
commit 98ad60c0b8
18 changed files with 1350 additions and 53 deletions

View File

@@ -1,8 +1,34 @@
(() => {
const $ = id => document.getElementById(id);
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' };
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '', editNamespace: '' };
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
// 与 platform/auth/token.go 的精确动作目录一致;不按能力组自动增加权限。
const permissionGroups = [
['任务', false, [['tasks:submit', '提交任务'], ['tasks:read', '查询任务'], ['tasks:cancel', '取消任务']]],
['队列', false, [['queues:read', '查询队列'], ['queues:manage', '管理队列']]],
['Worker 执行', false, [['workers:connect', '建立连接'], ['workers:consume', '领取与执行任务']]],
['结果订阅', false, [['events:read', '查询订阅与结果'], ['events:manage', '管理订阅'], ['events:consume', '消费与确认结果']]],
['定时调度', false, [['schedules:read', '查询计划'], ['schedules:manage', '管理计划']]],
['流程编排', false, [['workflows:definitions:read', '查询定义'], ['workflows:definitions:publish', '发布定义'], ['workflows:start', '启动流程'], ['workflows:read', '查询流程'], ['workflows:control', '控制流程'], ['workflows:signal', '发送信号']]],
['高级管理 · 人工恢复与审计', true, [['tasks:review', '核查任务结果'], ['tasks:retry', '人工重试任务'], ['tasks:audit:read', '读取任务操作审计'], ['workflows:recover', '人工恢复流程'], ['workflows:audit:read', '读取流程审计']]],
['高级管理 · 应用额度', true, [['application:resources:read', '查询应用额度'], ['application:resources:manage', '调整应用额度']]]
];
const permissionActions = new Set(permissionGroups.flatMap(([, , actions]) => actions.map(([action]) => action)));
function unsupportedPermissions(credential) {
const actions = credential.permissions.actions;
return actions.some(action => !permissionActions.has(action)) || new Set(actions).size !== actions.length;
}
function renderPermissionEditor(credential) {
const selected = new Set(credential.permissions.actions);
$('access-permission-scope').textContent = `命名空间:${ui.editNamespace} · 凭据:${credential.id} · 版本 ${credential.revision}`;
$('access-permission-current').textContent = credential.permissions.actions.join(' · ');
$('access-permission-queues').textContent = credential.permissions.queues.join(' · ');
$('access-permission-lifetime').textContent = `当前 Token 有效期:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 过渡截止:${time(credential.previous_valid_until)}。` : '无旧 Token 过渡。'}`;
$('access-permission-warning').hidden = !unsupportedPermissions(credential);
$('access-permission-options').innerHTML = permissionGroups.map(([name, advanced, actions]) => `<fieldset class="access-action-group${advanced ? ' access-action-advanced' : ''}"><legend>${escape(name)}</legend>${actions.map(([action, label]) => `<label class="access-action-option"><input type="checkbox" value="${escape(action)}" ${selected.has(action) ? 'checked' : ''}><span>${escape(label)}<code>${escape(action)}</code></span></label>`).join('')}</fieldset>`).join('');
}
let tokenRequest = 0, tokenTarget = null, selectionSequence = 0;
const resourceMessages = {
'resource is retired; history remains available':'资源已退役,历史仍可查询。',
@@ -102,7 +128,7 @@
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
${rotationHelp}
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-permissions="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整权限</button><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></details>
</section>`;
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
@@ -168,23 +194,28 @@
}
function open(credential = null, mode = 'ROTATE') {
if (ui.busy) return;
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true;
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.editNamespace = ui.selected; ui.pending = null; ui.result = null; ui.saved = true;
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
$('access-create-fields').hidden = !!credential;
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' };
$('access-permission-editor').hidden = ui.mode !== 'SET_PERMISSIONS';
if (ui.mode === 'SET_PERMISSIONS') renderPermissionEditor(credential);
$('access-permission-options').querySelectorAll('input').forEach(el => { el.disabled = ui.mode !== 'SET_PERMISSIONS'; });
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡', SET_PERMISSIONS: '调整权限' };
$('access-form-title').textContent = titles[ui.mode];
$('access-valid-field').hidden = ui.mode === 'FINALIZE';
$('access-valid-field').hidden = ['FINALIZE', 'SET_PERMISSIONS'].includes(ui.mode);
$('access-grace-field').hidden = ui.mode !== 'ROTATE';
$('access-form-note').textContent = {
CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。',
ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。',
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。',
SET_PERMISSIONS: '勾选的动作将完整替换当前权限,取消勾选会收回对应权限。当前和过渡期 Token 均使用调整后的权限;Token、有效期、过渡截止及队列范围保持不变。'
}[ui.mode];
$('access-token-once-note').hidden = !['CREATE', 'ROTATE'].includes(ui.mode);
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
$('access-submit').disabled = ui.mode === 'SET_PERMISSIONS' && unsupportedPermissions(credential);
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
}
function close() {
@@ -204,9 +235,16 @@
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
try {
if (!ui.pending) {
const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
const input = { kind: ui.mode, valid_days: ['FINALIZE', 'SET_PERMISSIONS'].includes(ui.mode) ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value);
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
if (ui.mode === 'SET_PERMISSIONS') {
if (unsupportedPermissions(ui.rotating)) throw new Error('当前凭据包含页面不支持或重复的动作,请更新控制台并核对权限后再操作。');
input.actions = [...$('access-permission-options').querySelectorAll('input:checked')].map(el => el.value).sort();
if (!input.actions.length) throw new Error('请至少保留一项权限。');
if (input.actions.some(action => !permissionActions.has(action)) || new Set(input.actions).size !== input.actions.length) throw new Error('权限选择无效,请重新打开调整权限。');
input.grace_days = 0;
}
if (ui.rotating) Object.assign(input, { namespace_id: ui.editNamespace, credential_id: ui.rotating.id });
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
ui.pending = await api('/prepare', input);
@@ -220,13 +258,14 @@
await select(op.namespace_id);
const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id);
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情后核对当前状态。');
if (op.input.kind === 'SET_PERMISSIONS' && JSON.stringify([...credential.permissions.actions].sort()) !== JSON.stringify([...op.input.actions].sort())) throw new Error('操作已提交,但当前权限暂时无法核对,请保留原操作重试确认。');
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : '';
ui.result = op; ui.pending = null;
$('access-form').hidden = true; $('access-result').hidden = false;
$('access-token-once-note').hidden = true;
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind];
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用', SET_PERMISSIONS: '权限已更新,原 Token 继续使用' }[op.input.kind];
$('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token;
$('access-result-config').value = preparedConfig;
$('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`;
@@ -241,12 +280,12 @@
$('access-url').value = location.origin;
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) { closeToken(); select(el.dataset.accessId); } });
$('access-credentials').addEventListener('click', e => {
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig;
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-permissions],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessPermissions || el.dataset.accessToken || el.dataset.accessCopyConfig;
const credential = ui.detail?.credentials.find(c => c.id === id);
if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; }
if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; }
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
if (credential && !credential.revoked_at) open(credential, el.dataset.accessPermissions ? 'SET_PERMISSIONS' : el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
});
$('access-help-close').addEventListener('click', () => $('access-help-dialog').close());
$('access-help-done').addEventListener('click', () => $('access-help-dialog').close());

View File

@@ -379,6 +379,14 @@
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
</div>
<section id="access-permission-editor" class="access-permission-editor" hidden aria-label="凭据动作权限">
<p class="panel-description" id="access-permission-scope"></p>
<details class="access-permission-details"><summary>当前精确权限</summary><p id="access-permission-current"></p></details>
<div class="access-permission-readonly"><strong>授权队列 · 只读</strong><code id="access-permission-queues"></code><small>星号表示当前命名空间内的全部队列。此次操作保留现有范围。</small><p id="access-permission-lifetime"></p></div>
<p class="action-error" id="access-permission-warning" role="alert" hidden>当前凭据包含页面不支持或重复的动作,已禁止提交。请更新控制台并核对权限,避免遗漏现有授权。</p>
<p class="panel-description">逐项选择所需权限。高级管理动作需明确勾选,不会随其他能力自动开通。</p>
<div id="access-permission-options"></div>
</section>
<div class="access-config-grid"><label id="access-valid-field">凭据有效期<select id="access-valid-days"><option value="0">长期有效</option><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
<label id="access-grace-field" hidden>旧 Token 过渡期<select id="access-grace-days"><option value="7">7 天</option><option value="1">1 天</option><option value="30">30 天</option></select><small>新旧 Token 同时可用。请在截止前完成所有服务的配置更新;旧 Token 原到期时间更早时,以原到期时间为准。</small></label>
<p class="action-error" id="access-form-error" role="alert"></p>

View File

@@ -151,3 +151,5 @@ input:focus-visible,textarea:focus-visible { outline:none; }
@media(max-width:640px){.data-panel .table-wrap::before{content:"左右滑动查看完整表格 →";display:block;padding:0 15px 10px;color:var(--muted);font-size:10px}.retention-panel{max-width:none}}
.login-audit-dialog{width:min(960px,calc(100vw - 32px))}.login-audit-dialog .table-wrap{margin:20px 0}.login-audit-dialog th,.login-audit-dialog td{padding:12px;text-align:left;white-space:nowrap}.login-audit-dialog .audit-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px}
.access-permission-editor{margin-top:18px}.access-permission-readonly{border:1px solid var(--line);border-radius:7px;background:var(--soft);padding:12px 14px;margin:14px 0;font-size:11px;color:var(--muted);line-height:1.8}.access-permission-readonly strong,.access-permission-readonly code,.access-permission-readonly small{display:block}.access-permission-readonly code{font-size:11px;color:var(--text);overflow-wrap:anywhere}.access-permission-readonly p{margin-top:8px}.access-action-group{border:1px solid var(--line);border-radius:7px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:14px;margin-top:15px}.access-action-group legend{padding:0 5px;font-size:12px;color:var(--text)}.access-action-option{display:flex;align-items:flex-start;gap:8px;cursor:pointer;color:var(--text);font-size:11px}.access-action-option input{margin:2px 0 0}.access-action-option code{display:block;font-size:10px;color:var(--muted);overflow-wrap:anywhere;line-height:1.7}.access-action-advanced{border-color:#eee2c5;background:#fffcf5}.access-action-advanced legend{color:#826838}@media(max-width:480px){.access-action-group{grid-template-columns:1fr}}

View File

@@ -15,7 +15,11 @@ import (
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
type fakeApplications struct{ calls int }
type fakeApplications struct {
calls int
input consoleauth.ApplicationInput
op consoleauth.ApplicationOperation
}
func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) {
f.calls++
@@ -25,12 +29,14 @@ func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID)
f.calls++
return consoleauth.ApplicationDetail{}, nil
}
func (f *fakeApplications) PrepareApplication(context.Context, auth.Token, consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
func (f *fakeApplications) PrepareApplication(_ context.Context, _ auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
f.calls++
f.input = in
return consoleauth.ApplicationOperation{}, nil
}
func (f *fakeApplications) ApplyApplication(context.Context, auth.Token, consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
func (f *fakeApplications) ApplyApplication(_ context.Context, _ auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
f.calls++
f.op = op
return consoleauth.ApplicationReceipt{}, nil
}
func TestConsoleApplicationSessionAndCSRF(t *testing.T) {
@@ -91,7 +97,7 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
queue, _ := core.NewID()
opID, _ := core.NewID()
token, _, _ := auth.Issue(ns, cred)
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} {
for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE", "SET_PERMISSIONS"} {
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()}
if kind == "CREATE" {
op.QueueID = queue
@@ -104,12 +110,16 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
op.Input.CredentialID = cred
op.Input.ExpectedRevision = 1
}
if kind == "SET_EXPIRY" || kind == "FINALIZE" {
if kind == "SET_EXPIRY" || kind == "FINALIZE" || kind == "SET_PERMISSIONS" {
op.Version = 2
op.Token = ""
op.ExpiresAt = nil
op.Input.ValidDays = 0
}
if kind == "SET_PERMISSIONS" {
op.Version = 3
op.Input.Actions = []string{"schedules:manage", "schedules:read", "tasks:submit"}
}
raw, err := json.Marshal(op)
if err != nil {
t.Fatal(err)
@@ -125,3 +135,88 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
}
}
}
// 权限操作仍走管理员 Cookie 与同源保护;新数组字段不能放宽精确 JSON 解码。
func TestConsolePermissionChangeHTTPBoundary(t *testing.T) {
ns, _ := core.NewID()
cred, _ := core.NewID()
raw, _, _ := auth.Issue(ns, cred)
valid := `{"kind":"SET_PERMISSIONS","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `","expected_revision":"7","reason":"enable scheduling","actions":["tasks:submit","schedules:read","schedules:manage"]}`
for _, apply := range []bool{false, true} {
path := "/console/applications/prepare"
wrap := func(in string) string { return in }
if apply {
path = "/console/applications/apply"
wrap = func(in string) string {
return `{"version":3,"input":` + in + `,"operation_id":"` + string(ns) + `","application_id":"` + string(ns) + `","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `"}`
}
}
for _, tc := range []struct {
name, body, origin, fetch string
cookie, validSession, header bool
status int
}{
{"valid", valid, "http://example.com", "same-origin", true, true, true, 200},
{"SDK bearer only", valid, "", "", false, true, true, 401},
{"expired session", valid, "", "", true, false, true, 401},
{"missing custom header", valid, "", "", true, true, false, 403},
{"cross origin", valid, "https://elsewhere.example", "", true, true, true, 403},
{"opaque origin", valid, "null", "", true, true, true, 403},
{"origin credentials", valid, "http://user@example.com", "", true, true, true, 403},
{"cross site metadata", valid, "http://example.com", "cross-site", true, true, true, 403},
{"duplicate actions", strings.Replace(valid, `"actions":`, `"actions":[],"actions":`, 1), "", "", true, true, true, 400},
{"field alias", strings.Replace(valid, `"actions":`, `"Actions":`, 1), "", "", true, true, true, 400},
{"unknown scope field", strings.TrimSuffix(valid, "}") + `,"queues":["*"]}`, "", "", true, true, true, 400},
{"null actions", strings.Replace(valid, `["tasks:submit","schedules:read","schedules:manage"]`, `null`, 1), "", "", true, true, true, 400},
{"null element", strings.Replace(valid, `"tasks:submit"`, `null`, 1), "", "", true, true, true, 400},
{"numeric element", strings.Replace(valid, `"tasks:submit"`, `12`, 1), "", "", true, true, true, 400},
{"numeric revision", strings.Replace(valid, `"expected_revision":"7"`, `"expected_revision":7`, 1), "", "", true, true, true, 400},
} {
t.Run(path+"/"+tc.name, func(t *testing.T) {
authStore := &fakeConsoleAuth{valid: tc.validSession}
store := &fakeApplications{}
server := NewWithConsoleAuth("", nil, authStore)
RegisterConsoleApplications(server, authStore, store)
req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(wrap(tc.body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+raw)
if tc.cookie {
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
}
if tc.header {
req.Header.Set("X-Orbit-Console", "1")
}
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
if tc.fetch != "" {
req.Header.Set("Sec-Fetch-Site", tc.fetch)
}
res := httptest.NewRecorder()
server.Handler.ServeHTTP(res, req)
if res.Code != tc.status {
t.Fatalf("status=%d want=%d", res.Code, tc.status)
}
if res.Header().Get("Cache-Control") != "no-store" {
t.Fatal("permission response is cacheable")
}
if tc.status != 200 {
if store.calls != 0 {
t.Fatal("rejected request reached store")
}
return
}
input := store.input
if apply {
input = store.op.Input
if store.op.Version != 3 || store.op.Token != "" {
t.Fatal("permission envelope changed")
}
}
if store.calls != 1 || input.Kind != "SET_PERMISSIONS" || input.ExpectedRevision != 7 || len(input.Actions) != 3 || input.Actions[2] != "schedules:manage" {
t.Fatal("permission intent changed")
}
})
}
}
}