openvibers/openvibe.media Commits
Resource index: an owner filter (a person's own objects) (#80)
Changes
@@@ -2,7 +2,7 @@/*** Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):*-- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1++ * GET /api/v1/resources[?project=&owner=&kind=&cursor=&limit=] → common.resource-list-result@1* GET /api/v1/resources/:ovrn → common.resource-summary@1** It pages the resources OpenVibe.Media owns — its media objects (med_<ULID>, media_objects) — as@@@ -31,6 +31,7 @@ const OBJECT_KIND = 'media.object';const KINDS = [OBJECT_KIND];const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;const DEFAULT_LIMIT = 100;const MAX_LIMIT = 1000;@@@ -78,12 +79,16 @@ const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.iconst SELECT_OBJECT = `SELECT o.*, a.project_id AS project FROM media_objects oLEFT JOIN apps a ON a.app_id = o.app_id`;--/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */--async function collect(db, { project = null, kind = null } = {}) {++/** The summaries matching the filters: `project` scopes tenancy, `owner` scopes subjects, `kind` picks one kind. Sorted by (kind, id). */++async function collect(db, { project = null, owner = null, kind = null } = {}) {if (kind && kind !== OBJECT_KIND) return [];-- const rows = project-- ? await db.all(`${SELECT_OBJECT} WHERE a.project_id = ? ORDER BY o.id`, [project])-- : await db.all(`${SELECT_OBJECT} ORDER BY o.id`);++ if (owner && !USER_SUBJECT_RE.test(owner)) return [];++ const conditions = [];++ const params = [];++ if (project) { conditions.push('a.project_id = ?'); params.push(project); }++ if (owner) { conditions.push('o.owner_subject = ?'); params.push(owner); }++ const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';++ const rows = await db.all(`${SELECT_OBJECT}${where} ORDER BY o.id`, params);return rows.map((o) => named(objectSummary(o))).sort(order);}@@@ -100,6 +105,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' || !OWNER_SUBJECT_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 !== '') {@@@ -111,7 +118,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, owner, kind, limit, cursor };}function router({ guard, db = require('../db/database') }) {@@@ -2,7 +2,7 @@// Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):// GET /api/v1/resources pages common.resource-summary@1 for the resources Media owns — its media objects// (med_, media_objects) — and GET /api/v1/resources/:ovrn reads one by its computed ovrn. Media lists--// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&kind=&cursor=&limit=++// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&owner=&kind=&cursor=&limit=// are honoured; ?project= is the tenancy boundary (the tenant's apps.project_id), so a resource of another// project is never returned. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf// composes one: only when the object's tenant is a developer project (a first-party tenant has no project).@@@ -37,7 +37,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';// ── Fixtures: two developer projects (A production + sandbox, B) and the first-party tenant `live` ──const prjA = ids.newId('project'); const prjB = ids.newId('project');-- const usrA = ids.newId('user');++ const usrA = ids.newId('user'); const usrB = ids.newId('user');const at = '2026-10-01 00:00:00'; // Media's stored shape; the index renders it RFC 3339const objA1 = ids.newId('media', 1700000001000); // project A, production, ready, user-ownedconst objA2 = ids.newId('media', 1700000002000); // project A, sandbox, uploading@@@ -51,9 +51,9 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';const INSERT_OBJECT = "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'private', ?, ?, ?)";const insertObject = (id, app, kind, owner, lifecycle) => db.run(INSERT_OBJECT, [id, app, app, kind, owner, lifecycle, at, at]);await insertObject(objA1, prjA, 'vod', usrA, 'ready');-- await insertObject(objA2, `${prjA}-sandbox`, 'vod', null, 'uploading');++ await insertObject(objA2, `${prjA}-sandbox`, 'vod', usrA, 'uploading');await insertObject(objA3, prjA, 'clip', null, 'deleted');-- await insertObject(objB1, prjB, 'file', null, 'ready');++ await insertObject(objB1, prjB, 'file', usrB, 'ready');await insertObject(objL1, 'live', 'file', null, 'ready');await insertObject(objL2, 'live', 'file', usrA, 'ready');@@@ -89,6 +89,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';assert.strictEqual(byId.get(objA2).project_id, prjA, 'the sandbox tenant of project A is project A');assert.strictEqual(byId.get(objA1).ovrn, ovrnOf(prjA, objA1), 'a project object is named');assert.strictEqual(byId.get(objA1).owner.id, usrA, 'a user-owned object names its owner');++ assert.strictEqual(byId.get(objB1).owner.id, usrB, 'another user-owned object names its own owner');assert.strictEqual(byId.get(objA1).state, 'ready');assert.strictEqual(byId.get(objA2).state, 'uploading');assert.strictEqual(byId.get(objA3).state, 'deleted', 'the state follows lifecycle_status');@@@ -114,6 +115,35 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');assert.deepStrictEqual((await aIds(`?project=${prjA}&limit=1000`)).sort(), [objA1, objA2, objA3].sort(), 'a large limit still pages one result');++ // ── ?owner= scopes the summary owner across tenants and combines with the other filters ──++ const ownedByA = [objA1, objA2, objL2].sort();++ assert.deepStrictEqual(await aIds(`?owner=${usrA}`), ownedByA, 'only this subject, including first-party rows');++ assert.deepStrictEqual(await aIds(`?owner=${usrB}`), [objB1], 'another subject has only its own rows');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=media.object`), ownedByA, 'owner and kind combine');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjA}&kind=media.object`), [objA1, objA2].sort(), 'owner, project and kind combine');++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjB}`), [], 'owner excludes another project');++ assert.deepStrictEqual(await aIds(`?owner=${usrA.replace(/^usr_/, 'agt_')}`), [], 'an agent has no user-owned summaries');++ assert.deepStrictEqual(await aIds('?owner='), idOrder, 'an empty owner leaves the index unfiltered');++++ const ownedPages = [];++ let ownerCursor = null;++ for (;;) {++ const q = `?owner=${usrA}&limit=1${ownerCursor ? `&cursor=${encodeURIComponent(ownerCursor)}` : ''}`;++ const y = await get(`/api/v1/resources${q}`, authHeaders);++ assert.strictEqual(y.status, 200, q);++ assert.ok(validate('common.resource-list-result@1', y.body).valid, q);++ y.body.resources.forEach((s) => {++ assert.ok(validate('common.resource-summary@1', s).valid, s.id);++ assert.strictEqual(s.owner.id, usrA);++ });++ ownedPages.push(...y.body.resources.map((s) => s.id));++ if (y.body.next_cursor === null) break;++ assert.ok(y.body.next_cursor && y.body.next_cursor !== ownerCursor);++ ownerCursor = y.body.next_cursor;++ if (ownedPages.length > ownedByA.length) assert.fail('the owner cursor chain never ended');++ }++ assert.deepStrictEqual(ownedPages, ownedByA, 'owner paging visits each row exactly once in order');++// ── Keyset paging: the whole index in pages of two, no duplicates, none skipped, order preserved ──const seen = [];let cursor = null;@@@ -156,7 +186,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`)).status, 401, 'a read without a token is 401 too');// ── A bad query is 400 resources.bad_query (an unknown kind stays an empty page) ──-- const bad = [['?project=nope', 'project not a prj_ id'], ['?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'], ['?owner=svc:live', 'owner is not a subject id'], ['?owner=usr_short', 'owner is too short'], [`?owner=${usrA}&owner=${usrB}`, 'multiple owners are not a subject id'], ['?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}`, authHeaders);assert.strictEqual(y.status, 400, why);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):
* Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):
*
*
- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1
+ * GET /api/v1/resources[?project=&owner=&kind=&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.Media owns — its media objects (med_<ULID>, media_objects) — as
* It pages the resources OpenVibe.Media owns — its media objects (med_<ULID>, media_objects) — as
@@ -31,6 +31,7 @@ const OBJECT_KIND = 'media.object';
@@ -31,6 +31,7 @@ const OBJECT_KIND = 'media.object';
const KINDS = [OBJECT_KIND];
const KINDS = [OBJECT_KIND];
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_ID_RE = /^prj_[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 OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const MAX_LIMIT = 1000;
@@ -78,12 +79,16 @@ const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.i
@@ -78,12 +79,16 @@ const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.i
const SELECT_OBJECT = `SELECT o.*, a.project_id AS project FROM media_objects o
const SELECT_OBJECT = `SELECT o.*, a.project_id AS project FROM media_objects o
LEFT JOIN apps a ON a.app_id = o.app_id`;
LEFT JOIN apps a ON a.app_id = o.app_id`;
-/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
-async function collect(db, { project = null, kind = null } = {}) {
+/** The summaries matching the filters: `project` scopes tenancy, `owner` scopes subjects, `kind` picks one kind. Sorted by (kind, id). */
+async function collect(db, { project = null, owner = null, kind = null } = {}) {
if (kind && kind !== OBJECT_KIND) return [];
if (kind && kind !== OBJECT_KIND) return [];
- const rows = project
- ? await db.all(`${SELECT_OBJECT} WHERE a.project_id = ? ORDER BY o.id`, [project])
- : await db.all(`${SELECT_OBJECT} ORDER BY o.id`);
+ if (owner && !USER_SUBJECT_RE.test(owner)) return [];
+ const conditions = [];
+ const params = [];
+ if (project) { conditions.push('a.project_id = ?'); params.push(project); }
+ if (owner) { conditions.push('o.owner_subject = ?'); params.push(owner); }
+ const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
+ const rows = await db.all(`${SELECT_OBJECT}${where} ORDER BY o.id`, params);
return rows.map((o) => named(objectSummary(o))).sort(order);
return rows.map((o) => named(objectSummary(o))).sort(order);
}
}
@@ -100,6 +105,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id
@@ -100,6 +105,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' || !OWNER_SUBJECT_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 !== '') {
@@ -111,7 +118,7 @@ function filtersOf(query) {
@@ -111,7 +118,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, owner, kind, limit, cursor };
}
}
function router({ guard, db = require('../db/database') }) {
function router({ guard, db = require('../db/database') }) {
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):
// Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):
// GET /api/v1/resources pages common.resource-summary@1 for the resources Media owns — its media objects
// GET /api/v1/resources pages common.resource-summary@1 for the resources Media owns — its media objects
// (med_, media_objects) — and GET /api/v1/resources/:ovrn reads one by its computed ovrn. Media lists
// (med_, media_objects) — and GET /api/v1/resources/:ovrn reads one by its computed ovrn. Media lists
-// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&kind=&cursor=&limit=
+// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&owner=&kind=&cursor=&limit=
// are honoured; ?project= is the tenancy boundary (the tenant's apps.project_id), so a resource of another
// are honoured; ?project= is the tenancy boundary (the tenant's apps.project_id), so a resource of another
// project is never returned. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
// project is never returned. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
// composes one: only when the object's tenant is a developer project (a first-party tenant has no project).
// composes one: only when the object's tenant is a developer project (a first-party tenant has no project).
@@ -37,7 +37,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -37,7 +37,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
// ── Fixtures: two developer projects (A production + sandbox, B) and the first-party tenant `live` ──
// ── Fixtures: two developer projects (A production + sandbox, B) and the first-party tenant `live` ──
const prjA = ids.newId('project'); const prjB = ids.newId('project');
const prjA = ids.newId('project'); const prjB = ids.newId('project');
- const usrA = ids.newId('user');
+ const usrA = ids.newId('user'); const usrB = ids.newId('user');
const at = '2026-10-01 00:00:00'; // Media's stored shape; the index renders it RFC 3339
const at = '2026-10-01 00:00:00'; // Media's stored shape; the index renders it RFC 3339
const objA1 = ids.newId('media', 1700000001000); // project A, production, ready, user-owned
const objA1 = ids.newId('media', 1700000001000); // project A, production, ready, user-owned
const objA2 = ids.newId('media', 1700000002000); // project A, sandbox, uploading
const objA2 = ids.newId('media', 1700000002000); // project A, sandbox, uploading
@@ -51,9 +51,9 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -51,9 +51,9 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
const INSERT_OBJECT = "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'private', ?, ?, ?)";
const INSERT_OBJECT = "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'private', ?, ?, ?)";
const insertObject = (id, app, kind, owner, lifecycle) => db.run(INSERT_OBJECT, [id, app, app, kind, owner, lifecycle, at, at]);
const insertObject = (id, app, kind, owner, lifecycle) => db.run(INSERT_OBJECT, [id, app, app, kind, owner, lifecycle, at, at]);
await insertObject(objA1, prjA, 'vod', usrA, 'ready');
await insertObject(objA1, prjA, 'vod', usrA, 'ready');
- await insertObject(objA2, `${prjA}-sandbox`, 'vod', null, 'uploading');
+ await insertObject(objA2, `${prjA}-sandbox`, 'vod', usrA, 'uploading');
await insertObject(objA3, prjA, 'clip', null, 'deleted');
await insertObject(objA3, prjA, 'clip', null, 'deleted');
- await insertObject(objB1, prjB, 'file', null, 'ready');
+ await insertObject(objB1, prjB, 'file', usrB, 'ready');
await insertObject(objL1, 'live', 'file', null, 'ready');
await insertObject(objL1, 'live', 'file', null, 'ready');
await insertObject(objL2, 'live', 'file', usrA, 'ready');
await insertObject(objL2, 'live', 'file', usrA, 'ready');
@@ -89,6 +89,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -89,6 +89,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
assert.strictEqual(byId.get(objA2).project_id, prjA, 'the sandbox tenant of project A is project A');
assert.strictEqual(byId.get(objA2).project_id, prjA, 'the sandbox tenant of project A is project A');
assert.strictEqual(byId.get(objA1).ovrn, ovrnOf(prjA, objA1), 'a project object is named');
assert.strictEqual(byId.get(objA1).ovrn, ovrnOf(prjA, objA1), 'a project object is named');
assert.strictEqual(byId.get(objA1).owner.id, usrA, 'a user-owned object names its owner');
assert.strictEqual(byId.get(objA1).owner.id, usrA, 'a user-owned object names its owner');
+ assert.strictEqual(byId.get(objB1).owner.id, usrB, 'another user-owned object names its own owner');
assert.strictEqual(byId.get(objA1).state, 'ready');
assert.strictEqual(byId.get(objA1).state, 'ready');
assert.strictEqual(byId.get(objA2).state, 'uploading');
assert.strictEqual(byId.get(objA2).state, 'uploading');
assert.strictEqual(byId.get(objA3).state, 'deleted', 'the state follows lifecycle_status');
assert.strictEqual(byId.get(objA3).state, 'deleted', 'the state follows lifecycle_status');
@@ -114,6 +115,35 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -114,6 +115,35 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
assert.deepStrictEqual((await aIds(`?project=${prjA}&limit=1000`)).sort(), [objA1, objA2, objA3].sort(), 'a large limit still pages one result');
assert.deepStrictEqual((await aIds(`?project=${prjA}&limit=1000`)).sort(), [objA1, objA2, objA3].sort(), 'a large limit still pages one result');
+ // ── ?owner= scopes the summary owner across tenants and combines with the other filters ──
+ const ownedByA = [objA1, objA2, objL2].sort();
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}`), ownedByA, 'only this subject, including first-party rows');
+ assert.deepStrictEqual(await aIds(`?owner=${usrB}`), [objB1], 'another subject has only its own rows');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=media.object`), ownedByA, 'owner and kind combine');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjA}&kind=media.object`), [objA1, objA2].sort(), 'owner, project and kind combine');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjB}`), [], 'owner excludes another project');
+ assert.deepStrictEqual(await aIds(`?owner=${usrA.replace(/^usr_/, 'agt_')}`), [], 'an agent has no user-owned summaries');
+ assert.deepStrictEqual(await aIds('?owner='), idOrder, 'an empty owner leaves the index unfiltered');
+
+ const ownedPages = [];
+ let ownerCursor = null;
+ for (;;) {
+ const q = `?owner=${usrA}&limit=1${ownerCursor ? `&cursor=${encodeURIComponent(ownerCursor)}` : ''}`;
+ const y = await get(`/api/v1/resources${q}`, authHeaders);
+ assert.strictEqual(y.status, 200, q);
+ assert.ok(validate('common.resource-list-result@1', y.body).valid, q);
+ y.body.resources.forEach((s) => {
+ assert.ok(validate('common.resource-summary@1', s).valid, s.id);
+ assert.strictEqual(s.owner.id, usrA);
+ });
+ ownedPages.push(...y.body.resources.map((s) => s.id));
+ if (y.body.next_cursor === null) break;
+ assert.ok(y.body.next_cursor && y.body.next_cursor !== ownerCursor);
+ ownerCursor = y.body.next_cursor;
+ if (ownedPages.length > ownedByA.length) assert.fail('the owner cursor chain never ended');
+ }
+ assert.deepStrictEqual(ownedPages, ownedByA, 'owner paging visits each row exactly once in order');
+
// ── Keyset paging: the whole index in pages of two, no duplicates, none skipped, order preserved ──
// ── Keyset paging: the whole index in pages of two, no duplicates, none skipped, order preserved ──
const seen = [];
const seen = [];
let cursor = null;
let cursor = null;
@@ -156,7 +186,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -156,7 +186,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`)).status, 401, 'a read without a token is 401 too');
assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`)).status, 401, 'a read without a token is 401 too');
// ── A bad query is 400 resources.bad_query (an unknown kind stays an empty page) ──
// ── A bad query is 400 resources.bad_query (an unknown kind stays an empty page) ──
- const bad = [['?project=nope', 'project not a prj_ id'], ['?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'], ['?owner=svc:live', 'owner is not a subject id'], ['?owner=usr_short', 'owner is too short'], [`?owner=${usrA}&owner=${usrB}`, 'multiple owners are not a subject id'], ['?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}`, authHeaders);
const y = await get(`/api/v1/resources${q}`, authHeaders);
assert.strictEqual(y.status, 400, why);
assert.strictEqual(y.status, 400, why);