openvibers/openvibe.services Commits
Services: Actor tasks can be stopped and Media objects deleted from the console; each kind offers its own actions (contracts 0.169) (#58)
Changes
@@@ -16,7 +16,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -890,9 +890,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.168.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.0",-- "integrity": "sha512-v59y6iRgSSHozx98grx5CRwPHN8UCj0L88HHJpJVBw/Cnzd2QzvcQQZIPlOMqH9K1QEEYeFUIJMiCIMJez0b5A==",++ "version": "0.169.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",++ "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -38,6 +38,18 @@ const { usageBody, usageQuery } = require('../render/usage');const { listQuery } = require('../api/v1');const { ServiceError } = require('../util');++// What the console offers on a resource (ADR-048 control), by kind. The owning service decides; a button it does not++// take would only be refused. `admins`: the service lets a project's admins act on what its apps made, not only the++// resource's own person (Actor and Media, contracts 0.169.0).++const OPEN_TASK = new Set(['queued', 'running', 'verifying']);++const KIND_ACTIONS = {++ 'watch.watch': { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },++ 'events.subscription': { actions: (x) => [x.state === 'disabled' ? ['resume', 'Resume'] : ['suspend', 'Suspend']] },++ 'actor.task': { admins: true, actions: (x) => (OPEN_TASK.has(x.state) ? [['stop', 'Stop']] : []) },++ 'media.object': { admins: true, actions: (x) => (x.state === 'deleted' ? [] : [['delete', 'Delete…']]) },++ default: { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },++};++const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -387,10 +399,18 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$// requests (its manifest lists an active <id>.resource.control). The service decides; Services shows its answer.const controllable = new Set(ctx.index.adapters.filter((a) => a.authority.control).map((a) => a.authority.id));const c = controls ? csrf(req) : null;++ // The page is an admin's (controls); they act on what they own, and on anything of the project at a service that++ // lets a project's admins act (KIND_ACTIONS `admins`: what its apps made). The service decides either way.++ const none = html`<span class="muted">—</span>`;const actionsCell = (x) => {-- if (!controls || !x.ovrn || !controllable.has(x.service) || !(x.owner && x.owner.type === 'user' && x.owner.id === me)) return html`<span class="muted">—</span>`;-- const act = (action, label) => html`<form method="post" action="/projects/${controls.project.id}/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="resource" value="${x.ovrn}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;-- return html`${x.state === 'paused' ? act('resume', 'Resume') : act('suspend', 'Suspend')} ${act('delete', 'Delete…')}`;++ if (!controls || !x.ovrn || !controllable.has(x.service)) return none;++ const kind = KIND_ACTIONS[x.kind] || KIND_ACTIONS.default;++ const mine = !!(x.owner && x.owner.type === 'user' && x.owner.id === me);++ if (!mine && !kind.admins) return none;++ const offered = kind.actions(x);++ if (!offered.length) return none;++ const act = ([action, label]) => html`<form method="post" action="/projects/${controls.project.id}/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="resource" value="${x.ovrn}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;++ return html`${offered.map((o, i) => html`${i ? ' ' : ''}${act(o)}`)}`;};const rows = result ? result.resources.map((x) => [html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,@@@ -439,7 +459,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht// One control operation from the console (ADR-048): the person's own request, made through server/domain/control.js// exactly as POST /api/v1/resources/control makes it (idempotency, the sensitive-action gate, the log and audit).// Delete asks first. The service that holds the resource decides; its answer is shown as it is.-- const CONSOLE_ACTIONS = { suspend: 'Suspended', resume: 'Resumed', delete: 'Deleted' };++ const CONSOLE_ACTIONS = { suspend: 'Suspended', resume: 'Resumed', stop: 'Stopped', delete: 'Deleted' };r.post('/:project/resources/control', idParams, B('services.resource.control'), form, guard, async (req, res) => {const project = await loadProject(req, res);if (!project) return;@@@ -448,7 +468,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? htconst b = req.body || {};const action = String(b.action || '');const resource = String(b.resource || '').slice(0, 300);-- if (!CONSOLE_ACTIONS[action]) return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>Suspend, resume or delete.</p><p><a href="${back}">Back</a></p>` }, 422);++ if (!CONSOLE_ACTIONS[action]) return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>Suspend, resume, stop or delete.</p><p><a href="${back}">Back</a></p>` }, 422);if (action === 'delete' && b.confirm !== 'yes') {return page(req, res, {title: 'Delete?', crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back }, { label: 'Delete' }],@@@ -88,10 +88,10 @@ function registryOf(list) {const prj = ids.newId('project');const object = resources.format({ service: 'media', project_id: prj, type: 'object', id: ids.newId('media') });-- await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168)', async () => {++ await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168, Actor and Media 0.169)', async () => {const real = createAuthorities(t.config, { log: { warn() {} } });assert.ok(real.size() > 0);-- const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control' };++ const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control', actor: 'actor.resource.control', media: 'media.resource.control' };for (const a of real.list()) assert.strictEqual(a.control, TAKES[a.id] || null, `${a.id}: the control capability in the pinned contracts`);});@@@ -246,7 +246,7 @@ function registryOf(list) {const page = await t.get(`/projects/${project}/resources`, { as: owner });assert.strictEqual(page.status, 200);assert.ok(page.text.includes('<th>Actions</th>') || page.text.includes('Actions'), 'an Actions column');-- assert.ok(page.text.includes('value="suspend"') && page.text.includes('value="delete"'));++ assert.ok(page.text.includes('value="delete"') && !page.text.includes('value="suspend"'), 'a media object takes delete only');const seenBefore = media.seen.length;const suspended = await t.get(`/projects/${project}/resources/control`, { as: owner, form: { resource: mine, action: 'suspend' } });assert.strictEqual(suspended.status, 303, suspended.text.slice(0, 300));@@@ -262,11 +262,31 @@ function registryOf(list) {assert.strictEqual(deleted.status, 303);assert.strictEqual(media.seen.at(-1).body.action, 'delete');const theirs = await t.get(`/projects/${project}/resources`, { as: viewer });-- assert.ok(!theirs.text.includes('value="suspend"'), 'a viewer gets no controls');++ assert.ok(!theirs.text.includes('value="delete"'), 'a viewer gets no controls');assert.strictEqual((await t.get(`/projects/${project}/resources/control`, { as: viewer, form: { resource: mine, action: 'suspend' } })).status, 403);media.list([]);});++ await check('the console offers what each kind takes; a project admin acts on what its apps made where the service allows it', async () => {++ const owner = t.network.addUser('kinds-owner');++ const project = await t.project(owner, 'Kinds project');++ const now = new Date().toISOString();++ const row = (kind, state, extra = {}) => {++ const [svc, type] = kind.split('.');++ const id = ids.newId(svc === 'media' ? 'media' : 'task');++ return { id, kind, service: 'media', project_id: project, name: `${kind} ${state}`, state, created_at: now, updated_at: now,++ ovrn: resources.format({ service: 'media', project_id: project, type, id }), ...extra };++ };++ const appMade = row('media.object', 'ready');++ const gone = row('media.object', 'deleted', { owner: { type: 'user', id: owner.subject } });++ media.list([appMade, gone]);++ const page = (await t.get(`/projects/${project}/resources`, { as: owner })).text;++ const cell = (x) => page.slice(page.indexOf(x.ovrn), page.indexOf('</tr>', page.indexOf(x.ovrn)));++ assert.ok(cell(appMade).includes('value="delete"'), 'an admin may delete what the project\'s app stored');++ assert.ok(!cell(gone).includes('<button'), 'a deleted object offers nothing');++ media.list([]);++ });++await check('one audit row per accepted request (a replay too); neither table holds params or a token', async () => {const before = (await audits()).length;const k = key();
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -890,9 +890,9 @@
@@ -890,9 +890,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.168.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.0",
- "integrity": "sha512-v59y6iRgSSHozx98grx5CRwPHN8UCj0L88HHJpJVBw/Cnzd2QzvcQQZIPlOMqH9K1QEEYeFUIJMiCIMJez0b5A==",
+ "version": "0.169.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
+ "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.168.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -38,6 +38,18 @@ const { usageBody, usageQuery } = require('../render/usage');
@@ -38,6 +38,18 @@ const { usageBody, usageQuery } = require('../render/usage');
const { listQuery } = require('../api/v1');
const { listQuery } = require('../api/v1');
const { ServiceError } = require('../util');
const { ServiceError } = require('../util');
+// What the console offers on a resource (ADR-048 control), by kind. The owning service decides; a button it does not
+// take would only be refused. `admins`: the service lets a project's admins act on what its apps made, not only the
+// resource's own person (Actor and Media, contracts 0.169.0).
+const OPEN_TASK = new Set(['queued', 'running', 'verifying']);
+const KIND_ACTIONS = {
+ 'watch.watch': { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },
+ 'events.subscription': { actions: (x) => [x.state === 'disabled' ? ['resume', 'Resume'] : ['suspend', 'Suspend']] },
+ 'actor.task': { admins: true, actions: (x) => (OPEN_TASK.has(x.state) ? [['stop', 'Stop']] : []) },
+ 'media.object': { admins: true, actions: (x) => (x.state === 'deleted' ? [] : [['delete', 'Delete…']]) },
+ default: { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },
+};
+
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -387,10 +399,18 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
@@ -387,10 +399,18 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
// requests (its manifest lists an active <id>.resource.control). The service decides; Services shows its answer.
// requests (its manifest lists an active <id>.resource.control). The service decides; Services shows its answer.
const controllable = new Set(ctx.index.adapters.filter((a) => a.authority.control).map((a) => a.authority.id));
const controllable = new Set(ctx.index.adapters.filter((a) => a.authority.control).map((a) => a.authority.id));
const c = controls ? csrf(req) : null;
const c = controls ? csrf(req) : null;
+ // The page is an admin's (controls); they act on what they own, and on anything of the project at a service that
+ // lets a project's admins act (KIND_ACTIONS `admins`: what its apps made). The service decides either way.
+ const none = html`<span class="muted">—</span>`;
const actionsCell = (x) => {
const actionsCell = (x) => {
- if (!controls || !x.ovrn || !controllable.has(x.service) || !(x.owner && x.owner.type === 'user' && x.owner.id === me)) return html`<span class="muted">—</span>`;
- const act = (action, label) => html`<form method="post" action="/projects/${controls.project.id}/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="resource" value="${x.ovrn}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;
- return html`${x.state === 'paused' ? act('resume', 'Resume') : act('suspend', 'Suspend')} ${act('delete', 'Delete…')}`;
+ if (!controls || !x.ovrn || !controllable.has(x.service)) return none;
+ const kind = KIND_ACTIONS[x.kind] || KIND_ACTIONS.default;
+ const mine = !!(x.owner && x.owner.type === 'user' && x.owner.id === me);
+ if (!mine && !kind.admins) return none;
+ const offered = kind.actions(x);
+ if (!offered.length) return none;
+ const act = ([action, label]) => html`<form method="post" action="/projects/${controls.project.id}/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="resource" value="${x.ovrn}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;
+ return html`${offered.map((o, i) => html`${i ? ' ' : ''}${act(o)}`)}`;
};
};
const rows = result ? result.resources.map((x) => [
const rows = result ? result.resources.map((x) => [
html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,
html`${x.name || x.id}<br><code class="small">${x.ovrn || x.id}</code>`,
@@ -439,7 +459,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
@@ -439,7 +459,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
// One control operation from the console (ADR-048): the person's own request, made through server/domain/control.js
// One control operation from the console (ADR-048): the person's own request, made through server/domain/control.js
// exactly as POST /api/v1/resources/control makes it (idempotency, the sensitive-action gate, the log and audit).
// exactly as POST /api/v1/resources/control makes it (idempotency, the sensitive-action gate, the log and audit).
// Delete asks first. The service that holds the resource decides; its answer is shown as it is.
// Delete asks first. The service that holds the resource decides; its answer is shown as it is.
- const CONSOLE_ACTIONS = { suspend: 'Suspended', resume: 'Resumed', delete: 'Deleted' };
+ const CONSOLE_ACTIONS = { suspend: 'Suspended', resume: 'Resumed', stop: 'Stopped', delete: 'Deleted' };
r.post('/:project/resources/control', idParams, B('services.resource.control'), form, guard, async (req, res) => {
r.post('/:project/resources/control', idParams, B('services.resource.control'), form, guard, async (req, res) => {
const project = await loadProject(req, res);
const project = await loadProject(req, res);
if (!project) return;
if (!project) return;
@@ -448,7 +468,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
@@ -448,7 +468,7 @@ ${result && (filter.cursor || result.next_cursor) ? html`<p>${filter.cursor ? ht
const b = req.body || {};
const b = req.body || {};
const action = String(b.action || '');
const action = String(b.action || '');
const resource = String(b.resource || '').slice(0, 300);
const resource = String(b.resource || '').slice(0, 300);
- if (!CONSOLE_ACTIONS[action]) return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>Suspend, resume or delete.</p><p><a href="${back}">Back</a></p>` }, 422);
+ if (!CONSOLE_ACTIONS[action]) return page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>Suspend, resume, stop or delete.</p><p><a href="${back}">Back</a></p>` }, 422);
if (action === 'delete' && b.confirm !== 'yes') {
if (action === 'delete' && b.confirm !== 'yes') {
return page(req, res, {
return page(req, res, {
title: 'Delete?', crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back }, { label: 'Delete' }],
title: 'Delete?', crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back }, { label: 'Delete' }],
@@ -88,10 +88,10 @@ function registryOf(list) {
@@ -88,10 +88,10 @@ function registryOf(list) {
const prj = ids.newId('project');
const prj = ids.newId('project');
const object = resources.format({ service: 'media', project_id: prj, type: 'object', id: ids.newId('media') });
const object = resources.format({ service: 'media', project_id: prj, type: 'object', id: ids.newId('media') });
- await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168)', async () => {
+ await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168, Actor and Media 0.169)', async () => {
const real = createAuthorities(t.config, { log: { warn() {} } });
const real = createAuthorities(t.config, { log: { warn() {} } });
assert.ok(real.size() > 0);
assert.ok(real.size() > 0);
- const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control' };
+ const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control', actor: 'actor.resource.control', media: 'media.resource.control' };
for (const a of real.list()) assert.strictEqual(a.control, TAKES[a.id] || null, `${a.id}: the control capability in the pinned contracts`);
for (const a of real.list()) assert.strictEqual(a.control, TAKES[a.id] || null, `${a.id}: the control capability in the pinned contracts`);
});
});
@@ -246,7 +246,7 @@ function registryOf(list) {
@@ -246,7 +246,7 @@ function registryOf(list) {
const page = await t.get(`/projects/${project}/resources`, { as: owner });
const page = await t.get(`/projects/${project}/resources`, { as: owner });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('<th>Actions</th>') || page.text.includes('Actions'), 'an Actions column');
assert.ok(page.text.includes('<th>Actions</th>') || page.text.includes('Actions'), 'an Actions column');
- assert.ok(page.text.includes('value="suspend"') && page.text.includes('value="delete"'));
+ assert.ok(page.text.includes('value="delete"') && !page.text.includes('value="suspend"'), 'a media object takes delete only');
const seenBefore = media.seen.length;
const seenBefore = media.seen.length;
const suspended = await t.get(`/projects/${project}/resources/control`, { as: owner, form: { resource: mine, action: 'suspend' } });
const suspended = await t.get(`/projects/${project}/resources/control`, { as: owner, form: { resource: mine, action: 'suspend' } });
assert.strictEqual(suspended.status, 303, suspended.text.slice(0, 300));
assert.strictEqual(suspended.status, 303, suspended.text.slice(0, 300));
@@ -262,11 +262,31 @@ function registryOf(list) {
@@ -262,11 +262,31 @@ function registryOf(list) {
assert.strictEqual(deleted.status, 303);
assert.strictEqual(deleted.status, 303);
assert.strictEqual(media.seen.at(-1).body.action, 'delete');
assert.strictEqual(media.seen.at(-1).body.action, 'delete');
const theirs = await t.get(`/projects/${project}/resources`, { as: viewer });
const theirs = await t.get(`/projects/${project}/resources`, { as: viewer });
- assert.ok(!theirs.text.includes('value="suspend"'), 'a viewer gets no controls');
+ assert.ok(!theirs.text.includes('value="delete"'), 'a viewer gets no controls');
assert.strictEqual((await t.get(`/projects/${project}/resources/control`, { as: viewer, form: { resource: mine, action: 'suspend' } })).status, 403);
assert.strictEqual((await t.get(`/projects/${project}/resources/control`, { as: viewer, form: { resource: mine, action: 'suspend' } })).status, 403);
media.list([]);
media.list([]);
});
});
+ await check('the console offers what each kind takes; a project admin acts on what its apps made where the service allows it', async () => {
+ const owner = t.network.addUser('kinds-owner');
+ const project = await t.project(owner, 'Kinds project');
+ const now = new Date().toISOString();
+ const row = (kind, state, extra = {}) => {
+ const [svc, type] = kind.split('.');
+ const id = ids.newId(svc === 'media' ? 'media' : 'task');
+ return { id, kind, service: 'media', project_id: project, name: `${kind} ${state}`, state, created_at: now, updated_at: now,
+ ovrn: resources.format({ service: 'media', project_id: project, type, id }), ...extra };
+ };
+ const appMade = row('media.object', 'ready');
+ const gone = row('media.object', 'deleted', { owner: { type: 'user', id: owner.subject } });
+ media.list([appMade, gone]);
+ const page = (await t.get(`/projects/${project}/resources`, { as: owner })).text;
+ const cell = (x) => page.slice(page.indexOf(x.ovrn), page.indexOf('</tr>', page.indexOf(x.ovrn)));
+ assert.ok(cell(appMade).includes('value="delete"'), 'an admin may delete what the project\'s app stored');
+ assert.ok(!cell(gone).includes('<button'), 'a deleted object offers nothing');
+ media.list([]);
+ });
+
await check('one audit row per accepted request (a replay too); neither table holds params or a token', async () => {
await check('one audit row per accepted request (a replay too); neither table holds params or a token', async () => {
const before = (await audits()).length;
const before = (await audits()).length;
const k = key();
const k = key();