openvibers/openvibe.events Commits
Resource index: an owner filter (a person's own subscriptions) (#54)
Changes
@@@ -2,7 +2,7 @@/*** The authority resource index (ADR-048 section 3; capability events.resource.read):*-- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] -> common.resource-list-result@1++ * GET /api/v1/resources[?project=&kind=&owner=&cursor=&limit=] -> common.resource-list-result@1* GET /api/v1/resources/:ovrn -> common.resource-summary@1** It pages the resources OpenVibe.Events owns — today its subscriptions (sub_), the kind@@@ -15,7 +15,8 @@* answer — the project-less ones are not its rows and are never mixed in. Without it the first-party caller* (the capability is first-party, resourceConstraints none) sees every subscription, which is what an* authority-wide fan-out needs; a subscription without a project (a service consumer's, first-party) is-- * exactly the row only that caller sees.++ * exactly the row only that caller sees. `?owner=usr_…` narrows the result to subscriptions whose consumer++ * is that raw user subject; this index does not emit agent owners.** OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one* formatter, so it is present exactly when the subscription is a nameable resource — a project-scoped one is@@@ -34,6 +35,7 @@ const SERVICE = 'events';const SUBSCRIPTION_KIND = 'events.subscription';const KINDS = [SUBSCRIPTION_KIND];const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const DEFAULT_LIMIT = 100;const MAX_LIMIT = 1000;@@@ -73,16 +75,17 @@ function named(summary) {const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);/**-- * The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id).++ * The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind, `owner` picks a++ * person whose subject is stored as the subscription consumer. Sorted by (kind, id).** Scope: events.resource.read is first-party (resourceConstraints none), so its holder sees every project and* ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that* principal's grants here instead of trusting the query.*/--async function collect(store, { project = null, kind = null } = {}) {++async function collect(store, { project = null, kind = null, owner = null } = {}) {const out = [];-- if (!kind || kind === SUBSCRIPTION_KIND) {-- const rows = project ? await store.listProjectSubscriptions(project) : await store.listSubscriptions();++ if ((!kind || kind === SUBSCRIPTION_KIND) && (!owner || USER_SUBJECT_RE.test(owner))) {++ const rows = project ? await store.listProjectSubscriptions(project, owner) : await store.listSubscriptions(owner);for (const s of rows) out.push(named(subscriptionSummary(s)));}return out.sort(order);@@@ -101,6 +104,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.idfunction filtersOf(query) {const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };++ const owner = query.owner === undefined || query.owner === '' ? null : query.owner;++ if (owner !== null && (typeof owner !== 'string' || !SUBJECT_ID_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;let limit = DEFAULT_LIMIT;if (typeof query.limit === 'string' && query.limit !== '') {@@@ -112,7 +117,7 @@ function filtersOf(query) {cursor = decodeCursor(query.cursor);if (!cursor) return { error: 'cursor is not one this index issued' };}-- return { project, kind, limit, cursor };++ return { project, kind, owner, limit, cursor };}function resourcesRouter({ store, auth }) {@@@ -499,9 +499,12 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage/*** Every subscription of one project, for the authority resource index (server/api/resources.js): `?project=prj_…`* is its tenancy boundary, so a row of another project — or a project-less first-party one — is never returned.++ * The optional consumer is the raw user subject used as the resource summary's owner id.*/-- async function listProjectSubscriptions(projectId) {-- return await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);++ async function listProjectSubscriptions(projectId, consumer = null) {++ return consumer++ ? await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? AND consumer = ? ORDER BY id').all(projectId, consumer)++ : await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);}/** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */async function rotateSubscriptionSecret(id, secret, overlapMs) {@@@ -2,7 +2,7 @@// Events' authority resource index (ADR-048 section 3; capability events.resource.read):// GET /api/v1/resources pages common.resource-summary@1 for the resources Events owns - its subscriptions// (sub_, kind events.subscription) - and GET /api/v1/resources/:ovrn reads one by its computed ovrn.--// ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of++// ?project=&kind=&owner=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of// another project - or a project-less one - is never returned under it, while the unscoped first-party// caller sees everything. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf// composes one: a project-scoped subscription is ovrn:events:<prj_…>:subscription/sub_…, a project-less@@@ -24,14 +24,18 @@ const sandbox = appToken({ env: 'sandbox', cap: ['events.resource.read'] });const prjA = `prj_${ids.ulid()}`;const prjB = `prj_${ids.ulid()}`;const usrA = ids.newId('user');++const usrB = ids.newId('user');const appA = ids.newId('app');const subSvc = ids.newId('subscription'); // first-party: a service consumer, no project (no name)const subUsr = ids.newId('subscription'); // a person's: the consumer is a usr_ subject, no project++const subUsr2 = ids.newId('subscription'); // another subscription for the same person++const subOther = ids.newId('subscription'); // another person's subscriptionconst subA1 = ids.newId('subscription'); // project Aconst subA2 = ids.newId('subscription'); // project A, later disabled++const subUsrProject = ids.newId('subscription'); // project A, person-ownedconst subB = ids.newId('subscription'); // project B// Sorted by (kind, id): one kind, so by id (ULID randomness, not creation order).--const expected = [subSvc, subUsr, subA1, subA2, subB].sort();++const expected = [subSvc, subUsr, subUsr2, subOther, subA1, subA2, subUsrProject, subB].sort();const ovrnOfSub = (project, id) => `ovrn:events:${project}:subscription/${id}`;const get = (p, token) => request(h.base, 'GET', p, { token });@@@ -45,8 +49,11 @@ t('boot + fixtures: first-party, person-owned and two projects\' subscriptions',});await add(subSvc, 'svc:live', 'live.*');await add(subUsr, usrA, 'live.vod.transcoded');++ await add(subUsr2, usrA, 'live.stream.started');++ await add(subOther, usrB, 'media.*');await add(subA1, `app:${appA}`, 'live.vod.*', prjA);await add(subA2, `app:${appA}`, 'media.*', prjA);++ await add(subUsrProject, usrA, 'games.*', prjA);await add(subB, `app:${ids.newId('app')}`, 'games.*', prjB);await h.store.setSubscriptionEnabled(subA2, false);});@@@ -105,8 +112,8 @@ t('each summary maps its own row: project_id, name, state, owner, and an ovrn onassert.strictEqual(byId.get(subA1).ovrn, ovrnOfSub(prjA, subA1));assert.strictEqual(byId.get(subB).ovrn, ovrnOfSub(prjB, subB));assert.deepStrictEqual(resources.parse(byId.get(subA1).ovrn), { service: 'events', project_id: prjA, type: 'subscription', id: subA1 });-- [subSvc, subUsr].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));-- [subSvc, subUsr].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));++ [subSvc, subUsr, subUsr2, subOther].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));++ [subSvc, subUsr, subUsr2, subOther].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));assert.deepStrictEqual(byId.get(subUsr).owner, { type: 'user', id: usrA }, 'a person-owned subscription names its owner');assert.ok(!('owner' in byId.get(subSvc)), 'a service consumer is not an owner');});@@@ -118,9 +125,9 @@ t('?project= is the tenancy boundary: only that project’s rows, never anotherassert.ok(validate('common.resource-list-result@1', y.body).valid);return y.body.resources.map((r) => r.id);};-- assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2].sort(), 'project A: never project B, never the project-less ones');++ assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2, subUsrProject].sort(), 'project A: never project B, never the project-less ones');assert.deepStrictEqual(await ids1(`?project=${prjB}`), [subB], 'project B is never mixed in');-- assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2].sort(), 'kind narrows within the project');++ assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2, subUsrProject].sort(), 'kind narrows within the project');assert.deepStrictEqual(await ids1(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');});@@@ -132,6 +139,42 @@ t('?kind= picks a kind; an unknown one (events.queue, events.unknown) is an emptassert.deepStrictEqual(await list('events.unknown'), []);});++t('?owner= selects one person, combines with kind and project, and pages that person once', async () => {++ const owned = [subUsr, subUsr2, subUsrProject].sort();++ const page = async (query) => {++ const y = await get(`/api/v1/resources?${query}`, reader);++ assert.strictEqual(y.status, 200, y.text);++ const check = validate('common.resource-list-result@1', y.body);++ assert.ok(check.valid, JSON.stringify(check.errors));++ return y.body;++ };++ assert.deepStrictEqual((await page(`owner=${usrA}`)).resources.map((r) => r.id), owned);++ assert.deepStrictEqual((await page(`owner=${usrB}`)).resources.map((r) => r.id), [subOther]);++ assert.deepStrictEqual((await page(`owner=${usrA}&kind=events.subscription`)).resources.map((r) => r.id), owned);++ assert.deepStrictEqual((await page(`owner=${usrA}&kind=events.queue`)).resources, []);++ assert.deepStrictEqual((await page(`owner=${usrA}&project=${prjA}`)).resources.map((r) => r.id), [subUsrProject]);++ assert.deepStrictEqual((await page(`owner=${usrA}&project=${prjA}&kind=events.subscription`)).resources.map((r) => r.id), [subUsrProject]);++ assert.deepStrictEqual((await page(`owner=${usrB}&project=${prjA}`)).resources, []);++ assert.deepStrictEqual((await page(`owner=agt_${ids.ulid()}`)).resources, [], 'this index emits only user owners');++ assert.deepStrictEqual((await page('owner=')).resources.map((r) => r.id), expected, 'empty owner does not filter');++++ const seen = [];++ let cursor = null;++ for (let pages = 0; ; pages++) {++ const query = `owner=${usrA}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;++ const y = await page(query);++ assert.ok(y.resources.length <= 1);++ for (const r of y.resources) {++ assert.deepStrictEqual(r.owner, { type: 'user', id: usrA });++ seen.push(r.id);++ }++ if (y.next_cursor === null) break;++ cursor = y.next_cursor;++ assert.ok(pages < owned.length, 'the owner cursor chain never ended');++ }++ assert.deepStrictEqual(seen, owned, 'every owned resource appears exactly once in order');++});++t('the cursor pages the whole index once, in order, with no duplicates', async () => {const seen = [];let cursor = null;@@@ -181,7 +224,7 @@ t('GET /:ovrn reads the same summary the list answers, and only for a name it co});t('a bad query is 400 resources.bad_query', async () => {-- const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];++ const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project too short'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?owner[]=x', 'owner not a scalar'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];for (const [q, why] of bad) {const y = await get(`/api/v1/resources${q}`, reader);assert.strictEqual(y.status, 400, `${why}: ${y.text}`);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* The authority resource index (ADR-048 section 3; capability events.resource.read):
* The authority resource index (ADR-048 section 3; capability events.resource.read):
*
*
- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] -> common.resource-list-result@1
+ * GET /api/v1/resources[?project=&kind=&owner=&cursor=&limit=] -> common.resource-list-result@1
* GET /api/v1/resources/:ovrn -> common.resource-summary@1
* GET /api/v1/resources/:ovrn -> common.resource-summary@1
*
*
* It pages the resources OpenVibe.Events owns — today its subscriptions (sub_), the kind
* It pages the resources OpenVibe.Events owns — today its subscriptions (sub_), the kind
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
* answer — the project-less ones are not its rows and are never mixed in. Without it the first-party caller
* answer — the project-less ones are not its rows and are never mixed in. Without it the first-party caller
* (the capability is first-party, resourceConstraints none) sees every subscription, which is what an
* (the capability is first-party, resourceConstraints none) sees every subscription, which is what an
* authority-wide fan-out needs; a subscription without a project (a service consumer's, first-party) is
* authority-wide fan-out needs; a subscription without a project (a service consumer's, first-party) is
- * exactly the row only that caller sees.
+ * exactly the row only that caller sees. `?owner=usr_…` narrows the result to subscriptions whose consumer
+ * is that raw user subject; this index does not emit agent owners.
*
*
* OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
* OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
* formatter, so it is present exactly when the subscription is a nameable resource — a project-scoped one is
* formatter, so it is present exactly when the subscription is a nameable resource — a project-scoped one is
@@ -34,6 +35,7 @@ const SERVICE = 'events';
@@ -34,6 +35,7 @@ const SERVICE = 'events';
const SUBSCRIPTION_KIND = 'events.subscription';
const SUBSCRIPTION_KIND = 'events.subscription';
const KINDS = [SUBSCRIPTION_KIND];
const KINDS = [SUBSCRIPTION_KIND];
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const MAX_LIMIT = 1000;
@@ -73,16 +75,17 @@ function named(summary) {
@@ -73,16 +75,17 @@ function named(summary) {
const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
/**
/**
- * The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id).
+ * The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind, `owner` picks a
+ * person whose subject is stored as the subscription consumer. Sorted by (kind, id).
*
*
* Scope: events.resource.read is first-party (resourceConstraints none), so its holder sees every project and
* Scope: events.resource.read is first-party (resourceConstraints none), so its holder sees every project and
* ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
* ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
* principal's grants here instead of trusting the query.
* principal's grants here instead of trusting the query.
*/
*/
-async function collect(store, { project = null, kind = null } = {}) {
+async function collect(store, { project = null, kind = null, owner = null } = {}) {
const out = [];
const out = [];
- if (!kind || kind === SUBSCRIPTION_KIND) {
- const rows = project ? await store.listProjectSubscriptions(project) : await store.listSubscriptions();
+ if ((!kind || kind === SUBSCRIPTION_KIND) && (!owner || USER_SUBJECT_RE.test(owner))) {
+ const rows = project ? await store.listProjectSubscriptions(project, owner) : await store.listSubscriptions(owner);
for (const s of rows) out.push(named(subscriptionSummary(s)));
for (const s of rows) out.push(named(subscriptionSummary(s)));
}
}
return out.sort(order);
return out.sort(order);
@@ -101,6 +104,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id
@@ -101,6 +104,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id
function filtersOf(query) {
function filtersOf(query) {
const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
+ const owner = query.owner === undefined || query.owner === '' ? null : query.owner;
+ if (owner !== null && (typeof owner !== 'string' || !SUBJECT_ID_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
let limit = DEFAULT_LIMIT;
let limit = DEFAULT_LIMIT;
if (typeof query.limit === 'string' && query.limit !== '') {
if (typeof query.limit === 'string' && query.limit !== '') {
@@ -112,7 +117,7 @@ function filtersOf(query) {
@@ -112,7 +117,7 @@ function filtersOf(query) {
cursor = decodeCursor(query.cursor);
cursor = decodeCursor(query.cursor);
if (!cursor) return { error: 'cursor is not one this index issued' };
if (!cursor) return { error: 'cursor is not one this index issued' };
}
}
- return { project, kind, limit, cursor };
+ return { project, kind, owner, limit, cursor };
}
}
function resourcesRouter({ store, auth }) {
function resourcesRouter({ store, auth }) {
@@ -499,9 +499,12 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
@@ -499,9 +499,12 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
/**
/**
* Every subscription of one project, for the authority resource index (server/api/resources.js): `?project=prj_…`
* Every subscription of one project, for the authority resource index (server/api/resources.js): `?project=prj_…`
* is its tenancy boundary, so a row of another project — or a project-less first-party one — is never returned.
* is its tenancy boundary, so a row of another project — or a project-less first-party one — is never returned.
+ * The optional consumer is the raw user subject used as the resource summary's owner id.
*/
*/
- async function listProjectSubscriptions(projectId) {
- return await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);
+ async function listProjectSubscriptions(projectId, consumer = null) {
+ return consumer
+ ? await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? AND consumer = ? ORDER BY id').all(projectId, consumer)
+ : await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);
}
}
/** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */
/** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */
async function rotateSubscriptionSecret(id, secret, overlapMs) {
async function rotateSubscriptionSecret(id, secret, overlapMs) {
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// Events' authority resource index (ADR-048 section 3; capability events.resource.read):
// Events' authority resource index (ADR-048 section 3; capability events.resource.read):
// GET /api/v1/resources pages common.resource-summary@1 for the resources Events owns - its subscriptions
// GET /api/v1/resources pages common.resource-summary@1 for the resources Events owns - its subscriptions
// (sub_, kind events.subscription) - and GET /api/v1/resources/:ovrn reads one by its computed ovrn.
// (sub_, kind events.subscription) - and GET /api/v1/resources/:ovrn reads one by its computed ovrn.
-// ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of
+// ?project=&kind=&owner=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of
// another project - or a project-less one - is never returned under it, while the unscoped first-party
// another project - or a project-less one - is never returned under it, while the unscoped first-party
// caller sees everything. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
// caller sees everything. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
// composes one: a project-scoped subscription is ovrn:events:<prj_…>:subscription/sub_…, a project-less
// composes one: a project-scoped subscription is ovrn:events:<prj_…>:subscription/sub_…, a project-less
@@ -24,14 +24,18 @@ const sandbox = appToken({ env: 'sandbox', cap: ['events.resource.read'] });
@@ -24,14 +24,18 @@ const sandbox = appToken({ env: 'sandbox', cap: ['events.resource.read'] });
const prjA = `prj_${ids.ulid()}`;
const prjA = `prj_${ids.ulid()}`;
const prjB = `prj_${ids.ulid()}`;
const prjB = `prj_${ids.ulid()}`;
const usrA = ids.newId('user');
const usrA = ids.newId('user');
+const usrB = ids.newId('user');
const appA = ids.newId('app');
const appA = ids.newId('app');
const subSvc = ids.newId('subscription'); // first-party: a service consumer, no project (no name)
const subSvc = ids.newId('subscription'); // first-party: a service consumer, no project (no name)
const subUsr = ids.newId('subscription'); // a person's: the consumer is a usr_ subject, no project
const subUsr = ids.newId('subscription'); // a person's: the consumer is a usr_ subject, no project
+const subUsr2 = ids.newId('subscription'); // another subscription for the same person
+const subOther = ids.newId('subscription'); // another person's subscription
const subA1 = ids.newId('subscription'); // project A
const subA1 = ids.newId('subscription'); // project A
const subA2 = ids.newId('subscription'); // project A, later disabled
const subA2 = ids.newId('subscription'); // project A, later disabled
+const subUsrProject = ids.newId('subscription'); // project A, person-owned
const subB = ids.newId('subscription'); // project B
const subB = ids.newId('subscription'); // project B
// Sorted by (kind, id): one kind, so by id (ULID randomness, not creation order).
// Sorted by (kind, id): one kind, so by id (ULID randomness, not creation order).
-const expected = [subSvc, subUsr, subA1, subA2, subB].sort();
+const expected = [subSvc, subUsr, subUsr2, subOther, subA1, subA2, subUsrProject, subB].sort();
const ovrnOfSub = (project, id) => `ovrn:events:${project}:subscription/${id}`;
const ovrnOfSub = (project, id) => `ovrn:events:${project}:subscription/${id}`;
const get = (p, token) => request(h.base, 'GET', p, { token });
const get = (p, token) => request(h.base, 'GET', p, { token });
@@ -45,8 +49,11 @@ t('boot + fixtures: first-party, person-owned and two projects\' subscriptions',
@@ -45,8 +49,11 @@ t('boot + fixtures: first-party, person-owned and two projects\' subscriptions',
});
});
await add(subSvc, 'svc:live', 'live.*');
await add(subSvc, 'svc:live', 'live.*');
await add(subUsr, usrA, 'live.vod.transcoded');
await add(subUsr, usrA, 'live.vod.transcoded');
+ await add(subUsr2, usrA, 'live.stream.started');
+ await add(subOther, usrB, 'media.*');
await add(subA1, `app:${appA}`, 'live.vod.*', prjA);
await add(subA1, `app:${appA}`, 'live.vod.*', prjA);
await add(subA2, `app:${appA}`, 'media.*', prjA);
await add(subA2, `app:${appA}`, 'media.*', prjA);
+ await add(subUsrProject, usrA, 'games.*', prjA);
await add(subB, `app:${ids.newId('app')}`, 'games.*', prjB);
await add(subB, `app:${ids.newId('app')}`, 'games.*', prjB);
await h.store.setSubscriptionEnabled(subA2, false);
await h.store.setSubscriptionEnabled(subA2, false);
});
});
@@ -105,8 +112,8 @@ t('each summary maps its own row: project_id, name, state, owner, and an ovrn on
@@ -105,8 +112,8 @@ t('each summary maps its own row: project_id, name, state, owner, and an ovrn on
assert.strictEqual(byId.get(subA1).ovrn, ovrnOfSub(prjA, subA1));
assert.strictEqual(byId.get(subA1).ovrn, ovrnOfSub(prjA, subA1));
assert.strictEqual(byId.get(subB).ovrn, ovrnOfSub(prjB, subB));
assert.strictEqual(byId.get(subB).ovrn, ovrnOfSub(prjB, subB));
assert.deepStrictEqual(resources.parse(byId.get(subA1).ovrn), { service: 'events', project_id: prjA, type: 'subscription', id: subA1 });
assert.deepStrictEqual(resources.parse(byId.get(subA1).ovrn), { service: 'events', project_id: prjA, type: 'subscription', id: subA1 });
- [subSvc, subUsr].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));
- [subSvc, subUsr].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));
+ [subSvc, subUsr, subUsr2, subOther].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));
+ [subSvc, subUsr, subUsr2, subOther].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));
assert.deepStrictEqual(byId.get(subUsr).owner, { type: 'user', id: usrA }, 'a person-owned subscription names its owner');
assert.deepStrictEqual(byId.get(subUsr).owner, { type: 'user', id: usrA }, 'a person-owned subscription names its owner');
assert.ok(!('owner' in byId.get(subSvc)), 'a service consumer is not an owner');
assert.ok(!('owner' in byId.get(subSvc)), 'a service consumer is not an owner');
});
});
@@ -118,9 +125,9 @@ t('?project= is the tenancy boundary: only that project’s rows, never another
@@ -118,9 +125,9 @@ t('?project= is the tenancy boundary: only that project’s rows, never another
assert.ok(validate('common.resource-list-result@1', y.body).valid);
assert.ok(validate('common.resource-list-result@1', y.body).valid);
return y.body.resources.map((r) => r.id);
return y.body.resources.map((r) => r.id);
};
};
- assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2].sort(), 'project A: never project B, never the project-less ones');
+ assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2, subUsrProject].sort(), 'project A: never project B, never the project-less ones');
assert.deepStrictEqual(await ids1(`?project=${prjB}`), [subB], 'project B is never mixed in');
assert.deepStrictEqual(await ids1(`?project=${prjB}`), [subB], 'project B is never mixed in');
- assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2].sort(), 'kind narrows within the project');
+ assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2, subUsrProject].sort(), 'kind narrows within the project');
assert.deepStrictEqual(await ids1(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
assert.deepStrictEqual(await ids1(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
});
});
@@ -132,6 +139,42 @@ t('?kind= picks a kind; an unknown one (events.queue, events.unknown) is an empt
@@ -132,6 +139,42 @@ t('?kind= picks a kind; an unknown one (events.queue, events.unknown) is an empt
assert.deepStrictEqual(await list('events.unknown'), []);
assert.deepStrictEqual(await list('events.unknown'), []);
});
});
+t('?owner= selects one person, combines with kind and project, and pages that person once', async () => {
+ const owned = [subUsr, subUsr2, subUsrProject].sort();
+ const page = async (query) => {
+ const y = await get(`/api/v1/resources?${query}`, reader);
+ assert.strictEqual(y.status, 200, y.text);
+ const check = validate('common.resource-list-result@1', y.body);
+ assert.ok(check.valid, JSON.stringify(check.errors));
+ return y.body;
+ };
+ assert.deepStrictEqual((await page(`owner=${usrA}`)).resources.map((r) => r.id), owned);
+ assert.deepStrictEqual((await page(`owner=${usrB}`)).resources.map((r) => r.id), [subOther]);
+ assert.deepStrictEqual((await page(`owner=${usrA}&kind=events.subscription`)).resources.map((r) => r.id), owned);
+ assert.deepStrictEqual((await page(`owner=${usrA}&kind=events.queue`)).resources, []);
+ assert.deepStrictEqual((await page(`owner=${usrA}&project=${prjA}`)).resources.map((r) => r.id), [subUsrProject]);
+ assert.deepStrictEqual((await page(`owner=${usrA}&project=${prjA}&kind=events.subscription`)).resources.map((r) => r.id), [subUsrProject]);
+ assert.deepStrictEqual((await page(`owner=${usrB}&project=${prjA}`)).resources, []);
+ assert.deepStrictEqual((await page(`owner=agt_${ids.ulid()}`)).resources, [], 'this index emits only user owners');
+ assert.deepStrictEqual((await page('owner=')).resources.map((r) => r.id), expected, 'empty owner does not filter');
+
+ const seen = [];
+ let cursor = null;
+ for (let pages = 0; ; pages++) {
+ const query = `owner=${usrA}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
+ const y = await page(query);
+ assert.ok(y.resources.length <= 1);
+ for (const r of y.resources) {
+ assert.deepStrictEqual(r.owner, { type: 'user', id: usrA });
+ seen.push(r.id);
+ }
+ if (y.next_cursor === null) break;
+ cursor = y.next_cursor;
+ assert.ok(pages < owned.length, 'the owner cursor chain never ended');
+ }
+ assert.deepStrictEqual(seen, owned, 'every owned resource appears exactly once in order');
+});
+
t('the cursor pages the whole index once, in order, with no duplicates', async () => {
t('the cursor pages the whole index once, in order, with no duplicates', async () => {
const seen = [];
const seen = [];
let cursor = null;
let cursor = null;
@@ -181,7 +224,7 @@ t('GET /:ovrn reads the same summary the list answers, and only for a name it co
@@ -181,7 +224,7 @@ t('GET /:ovrn reads the same summary the list answers, and only for a name it co
});
});
t('a bad query is 400 resources.bad_query', async () => {
t('a bad query is 400 resources.bad_query', async () => {
- const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
+ const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project too short'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?owner[]=x', 'owner not a scalar'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
for (const [q, why] of bad) {
for (const [q, why] of bad) {
const y = await get(`/api/v1/resources${q}`, reader);
const y = await get(`/api/v1/resources${q}`, reader);
assert.strictEqual(y.status, 400, `${why}: ${y.text}`);
assert.strictEqual(y.status, 400, `${why}: ${y.text}`);