openvibers/openvibe.host Commits
Resource index: an owner filter (a person's own sites, deploys and domains) (#60)
Changes
@@@ -2,7 +2,7 @@/*** Host's authority resource index (ADR-048 section 3; capability host.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.Host owns — its tenant sites (sit_), the immutable deploys of those@@@ -38,6 +38,7 @@ const DEPLOY_KIND = 'host.deploy';const DOMAIN_KIND = 'host.domain';const KINDS = [SITE_KIND, DEPLOY_KIND, DOMAIN_KIND];const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const OWNER_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;@@@ -86,28 +87,29 @@ function named(summary) {/** A stable (kind, id) ordering, so a cursor can be a position in it. */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 project, kind and owner filters, sorted by (kind, id). */// Scope: host.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(db, { project = null, kind = null } = {}) {++async function collect(db, { project = null, kind = null, owner = null } = {}) {++ // Only usr_ subjects become summary owners; an agt_ subject cannot match any Host summary.++ if (owner && !USER_SUBJECT_RE.test(owner)) return [];++ const clauses = [];++ const values = [];++ if (project) { clauses.push('project_id = ?'); values.push(project); }++ if (owner) { clauses.push('created_by = ?'); values.push(owner); }++ const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';const out = [];if (!kind || kind === SITE_KIND) {-- const rows = project-- ? await db.prepare('SELECT * FROM host_sites WHERE project_id = ? ORDER BY id').all(project)-- : await db.prepare('SELECT * FROM host_sites ORDER BY id').all();++ const rows = await db.prepare(`SELECT * FROM host_sites${where} ORDER BY id`).all(...values);for (const r of rows) out.push(named(siteSummary(r)));}if (!kind || kind === DEPLOY_KIND) {-- const rows = project-- ? await db.prepare('SELECT * FROM host_deploys WHERE project_id = ? ORDER BY id').all(project)-- : await db.prepare('SELECT * FROM host_deploys ORDER BY id').all();++ const rows = await db.prepare(`SELECT * FROM host_deploys${where} ORDER BY id`).all(...values);for (const r of rows) out.push(named(deploySummary(r)));}if (!kind || kind === DOMAIN_KIND) {-- const rows = project-- ? await db.prepare('SELECT * FROM host_domains WHERE project_id = ? ORDER BY id').all(project)-- : await db.prepare('SELECT * FROM host_domains ORDER BY id').all();++ const rows = await db.prepare(`SELECT * FROM host_domains${where} ORDER BY id`).all(...values);for (const r of rows) out.push(named(domainSummary(r)));}return out.sort(order);@@@ -126,6 +128,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 = typeof query.owner === 'string' && query.owner !== '' ? query.owner : null;++ if (query.owner != null && query.owner !== '' && (!owner || !OWNER_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 !== '') {@@@ -137,7 +141,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 };}/** The OVRN type segment → the table and summary it belongs to. */@@@ -3,7 +3,7 @@// GET /api/v1/resources pages common.resource-summary@1 for the resources Host owns - its tenant sites// (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and// custom domains (host.domain, dom_) - as common.resource-list-result@1, and GET /api/v1/resources/:ovrn--// reads one by its computed ovrn. ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy++// reads one by its computed ovrn. ?project=&kind=&owner=&cursor=&limit= are honoured; ?project= is the tenancy// boundary, so a resource of another project is never returned, and Host lists no projects at all.// Every summary and every page is validated against the released schemas.const assert = require('assert');@@@ -13,13 +13,16 @@ const { boot, check, done } = require('./stageb/boot');(async () => {const t = await boot();const alice = t.user('alice');++ const bob = t.user('bob');const prjA = (await t.project(alice, 'Resource A')).id;const prjB = (await t.project(alice, 'Resource B')).id;++ const prjC = (await t.project(bob, 'Resource C')).id;-- // Sites: two in A, one in B (each creation also mints the site's default domain).++ // Sites: two in A, one in B and one owned by Bob in C (each mints a default domain).const siteA1 = await t.site(alice, prjA, 'ri-a-one');const siteA2 = await t.site(alice, prjA, 'ri-a-two');const siteB = await t.site(alice, prjB, 'ri-b-one');++ const siteC = await t.site(bob, prjC, 'ri-c-one');// Deploys of A's first site: one ready, one refused (a failed deploy).const readyDeploy = await t.deploy(alice, siteA1.id, { 'index.html': 'v1' });@@@ -35,12 +38,12 @@ const { boot, check, done } = require('./stageb/boot');// The default domain each site create mints (its id is what the index lists).const defaultDomains = [];-- for (const site of [siteA1, siteA2, siteB]) {-- const seen = await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice });++ for (const [site, user] of [[siteA1, alice], [siteA2, alice], [siteB, alice], [siteC, bob]]) {++ const seen = await t.api('GET', `/api/v1/sites/${site.id}`, { as: user });assert.strictEqual(seen.status, 200, seen.text);for (const d of seen.json().domains) if (d.kind === 'default') defaultDomains.push(d);}-- assert.strictEqual(defaultDomains.length, 3);++ assert.strictEqual(defaultDomains.length, 4);const resourcesOf = async (query, headers) => {const r = await t.api('GET', `/api/v1/resources${query}`, headers);@@@ -81,7 +84,7 @@ const { boot, check, done } = require('./stageb/boot');const expected = [{ kind: 'host.deploy', id: readyDeploy.id }, { kind: 'host.deploy', id: failedDeploy.id },{ kind: 'host.domain', id: customDomain.id },-- { kind: 'host.site', id: siteA1.id }, { kind: 'host.site', id: siteA2.id }, { kind: 'host.site', id: siteB.id },++ { kind: 'host.site', id: siteA1.id }, { kind: 'host.site', id: siteA2.id }, { kind: 'host.site', id: siteB.id }, { kind: 'host.site', id: siteC.id },];const byId = new Map(page.resources.map((r) => [r.id, r]));for (const d of defaultDomains) expected.push({ kind: 'host.domain', id: d.id });@@@ -127,16 +130,45 @@ const { boot, check, done } = require('./stageb/boot');await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {const sites = await resourcesOf('?kind=host.site', auth);-- assert.deepStrictEqual(sites.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id, siteB.id].sort());++ assert.deepStrictEqual(sites.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id, siteB.id, siteC.id].sort());const deploys = await resourcesOf('?kind=host.deploy', auth);assert.deepStrictEqual(deploys.resources.map((r) => r.id).sort(), [readyDeploy.id, failedDeploy.id].sort());const domains = await resourcesOf('?kind=host.domain', auth);-- assert.strictEqual(domains.resources.length, 4, "three default domains and A's custom one");++ assert.strictEqual(domains.resources.length, 5, "four default domains and A's custom one");const scoped = await resourcesOf(`?project=${prjA}&kind=host.site`, auth);assert.deepStrictEqual(scoped.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id].sort(), 'kind narrows within the project');assert.deepStrictEqual((await resourcesOf('?kind=host.unknown', auth)).resources, []);});++ await check('?owner= filters every kind and combines with project, kind and cursor', async () => {++ const all = (await resourcesOf('', auth)).resources;++ const aliceRows = all.filter((r) => r.owner && r.owner.id === alice.subject);++ const bobRows = all.filter((r) => r.owner && r.owner.id === bob.subject);++ assert.deepStrictEqual(bobRows.map((r) => r.id).sort(), [siteC.id, defaultDomains.find((d) => d.site_id === siteC.id).id].sort());++ const own = await resourcesOf(`?owner=${alice.subject}`, auth);++ assert.deepStrictEqual(own.resources, aliceRows, 'only Alice-owned summaries in index order');++ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}`, auth)).resources, bobRows, 'Bob owns a site and its default domain');++ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&kind=host.site&project=${prjA}`, auth)).resources,++ aliceRows.filter((r) => r.kind === 'host.site' && r.project_id === prjA));++ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}&project=${prjA}`, auth)).resources, []);++ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&project=${prjC}`, auth)).resources, []);++ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject.replace(/^usr_/, 'agt_')}`, auth)).resources, []);++ assert.deepStrictEqual((await resourcesOf('?owner=', auth)).resources, all, 'empty owner means no filter');++++ const seen = [];++ let cursor = null;++ let pages = 0;++ for (;;) {++ const page = await resourcesOf(`?owner=${alice.subject}&limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);++ assert.ok(page.resources.length <= 2);++ seen.push(...page.resources);++ if (page.next_cursor === null) break;++ cursor = page.next_cursor;++ if (++pages > 50) assert.fail('the owner cursor chain never ended');++ }++ assert.deepStrictEqual(seen, aliceRows, 'owner paging has no duplicates or gaps');++ });++await check('the cursor pages the index without duplicates, gaps or reordering', async () => {const whole = (await resourcesOf('', auth)).resources.map((r) => r.id);const seen = [];@@@ -180,7 +212,7 @@ const { boot, check, done } = require('./stageb/boot');});await check('a query that cannot be honoured is 400 resources.bad_query', async () => {-- 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 not a subject id'], ['?owner=usr_short', 'owner 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']];for (const [q, why] of bad) {const r = await t.api('GET', `/api/v1/resources${q}`, auth);assert.strictEqual(r.status, 400, why);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Host's authority resource index (ADR-048 section 3; capability host.resource.read):
* Host's authority resource index (ADR-048 section 3; capability host.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.Host owns — its tenant sites (sit_), the immutable deploys of those
* It pages the resources OpenVibe.Host owns — its tenant sites (sit_), the immutable deploys of those
@@ -38,6 +38,7 @@ const DEPLOY_KIND = 'host.deploy';
@@ -38,6 +38,7 @@ const DEPLOY_KIND = 'host.deploy';
const DOMAIN_KIND = 'host.domain';
const DOMAIN_KIND = 'host.domain';
const KINDS = [SITE_KIND, DEPLOY_KIND, DOMAIN_KIND];
const KINDS = [SITE_KIND, DEPLOY_KIND, DOMAIN_KIND];
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const OWNER_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;
@@ -86,28 +87,29 @@ function named(summary) {
@@ -86,28 +87,29 @@ function named(summary) {
/** A stable (kind, id) ordering, so a cursor can be a position in it. */
/** A stable (kind, id) ordering, so a cursor can be a position in it. */
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 project, kind and owner filters, sorted by (kind, id). */
// Scope: host.resource.read is first-party (resourceConstraints none), so its holder sees every project and
// Scope: host.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(db, { project = null, kind = null } = {}) {
+async function collect(db, { project = null, kind = null, owner = null } = {}) {
+ // Only usr_ subjects become summary owners; an agt_ subject cannot match any Host summary.
+ if (owner && !USER_SUBJECT_RE.test(owner)) return [];
+ const clauses = [];
+ const values = [];
+ if (project) { clauses.push('project_id = ?'); values.push(project); }
+ if (owner) { clauses.push('created_by = ?'); values.push(owner); }
+ const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
const out = [];
const out = [];
if (!kind || kind === SITE_KIND) {
if (!kind || kind === SITE_KIND) {
- const rows = project
- ? await db.prepare('SELECT * FROM host_sites WHERE project_id = ? ORDER BY id').all(project)
- : await db.prepare('SELECT * FROM host_sites ORDER BY id').all();
+ const rows = await db.prepare(`SELECT * FROM host_sites${where} ORDER BY id`).all(...values);
for (const r of rows) out.push(named(siteSummary(r)));
for (const r of rows) out.push(named(siteSummary(r)));
}
}
if (!kind || kind === DEPLOY_KIND) {
if (!kind || kind === DEPLOY_KIND) {
- const rows = project
- ? await db.prepare('SELECT * FROM host_deploys WHERE project_id = ? ORDER BY id').all(project)
- : await db.prepare('SELECT * FROM host_deploys ORDER BY id').all();
+ const rows = await db.prepare(`SELECT * FROM host_deploys${where} ORDER BY id`).all(...values);
for (const r of rows) out.push(named(deploySummary(r)));
for (const r of rows) out.push(named(deploySummary(r)));
}
}
if (!kind || kind === DOMAIN_KIND) {
if (!kind || kind === DOMAIN_KIND) {
- const rows = project
- ? await db.prepare('SELECT * FROM host_domains WHERE project_id = ? ORDER BY id').all(project)
- : await db.prepare('SELECT * FROM host_domains ORDER BY id').all();
+ const rows = await db.prepare(`SELECT * FROM host_domains${where} ORDER BY id`).all(...values);
for (const r of rows) out.push(named(domainSummary(r)));
for (const r of rows) out.push(named(domainSummary(r)));
}
}
return out.sort(order);
return out.sort(order);
@@ -126,6 +128,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id
@@ -126,6 +128,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 = typeof query.owner === 'string' && query.owner !== '' ? query.owner : null;
+ if (query.owner != null && query.owner !== '' && (!owner || !OWNER_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 !== '') {
@@ -137,7 +141,7 @@ function filtersOf(query) {
@@ -137,7 +141,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 };
}
}
/** The OVRN type segment → the table and summary it belongs to. */
/** The OVRN type segment → the table and summary it belongs to. */
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// GET /api/v1/resources pages common.resource-summary@1 for the resources Host owns - its tenant sites
// GET /api/v1/resources pages common.resource-summary@1 for the resources Host owns - its tenant sites
// (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and
// (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and
// custom domains (host.domain, dom_) - as common.resource-list-result@1, and GET /api/v1/resources/:ovrn
// custom domains (host.domain, dom_) - as common.resource-list-result@1, and GET /api/v1/resources/:ovrn
-// reads one by its computed ovrn. ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
+// reads one by its computed ovrn. ?project=&kind=&owner=&cursor=&limit= are honoured; ?project= is the tenancy
// boundary, so a resource of another project is never returned, and Host lists no projects at all.
// boundary, so a resource of another project is never returned, and Host lists no projects at all.
// Every summary and every page is validated against the released schemas.
// Every summary and every page is validated against the released schemas.
const assert = require('assert');
const assert = require('assert');
@@ -13,13 +13,16 @@ const { boot, check, done } = require('./stageb/boot');
@@ -13,13 +13,16 @@ const { boot, check, done } = require('./stageb/boot');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const alice = t.user('alice');
const alice = t.user('alice');
+ const bob = t.user('bob');
const prjA = (await t.project(alice, 'Resource A')).id;
const prjA = (await t.project(alice, 'Resource A')).id;
const prjB = (await t.project(alice, 'Resource B')).id;
const prjB = (await t.project(alice, 'Resource B')).id;
+ const prjC = (await t.project(bob, 'Resource C')).id;
- // Sites: two in A, one in B (each creation also mints the site's default domain).
+ // Sites: two in A, one in B and one owned by Bob in C (each mints a default domain).
const siteA1 = await t.site(alice, prjA, 'ri-a-one');
const siteA1 = await t.site(alice, prjA, 'ri-a-one');
const siteA2 = await t.site(alice, prjA, 'ri-a-two');
const siteA2 = await t.site(alice, prjA, 'ri-a-two');
const siteB = await t.site(alice, prjB, 'ri-b-one');
const siteB = await t.site(alice, prjB, 'ri-b-one');
+ const siteC = await t.site(bob, prjC, 'ri-c-one');
// Deploys of A's first site: one ready, one refused (a failed deploy).
// Deploys of A's first site: one ready, one refused (a failed deploy).
const readyDeploy = await t.deploy(alice, siteA1.id, { 'index.html': 'v1' });
const readyDeploy = await t.deploy(alice, siteA1.id, { 'index.html': 'v1' });
@@ -35,12 +38,12 @@ const { boot, check, done } = require('./stageb/boot');
@@ -35,12 +38,12 @@ const { boot, check, done } = require('./stageb/boot');
// The default domain each site create mints (its id is what the index lists).
// The default domain each site create mints (its id is what the index lists).
const defaultDomains = [];
const defaultDomains = [];
- for (const site of [siteA1, siteA2, siteB]) {
- const seen = await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice });
+ for (const [site, user] of [[siteA1, alice], [siteA2, alice], [siteB, alice], [siteC, bob]]) {
+ const seen = await t.api('GET', `/api/v1/sites/${site.id}`, { as: user });
assert.strictEqual(seen.status, 200, seen.text);
assert.strictEqual(seen.status, 200, seen.text);
for (const d of seen.json().domains) if (d.kind === 'default') defaultDomains.push(d);
for (const d of seen.json().domains) if (d.kind === 'default') defaultDomains.push(d);
}
}
- assert.strictEqual(defaultDomains.length, 3);
+ assert.strictEqual(defaultDomains.length, 4);
const resourcesOf = async (query, headers) => {
const resourcesOf = async (query, headers) => {
const r = await t.api('GET', `/api/v1/resources${query}`, headers);
const r = await t.api('GET', `/api/v1/resources${query}`, headers);
@@ -81,7 +84,7 @@ const { boot, check, done } = require('./stageb/boot');
@@ -81,7 +84,7 @@ const { boot, check, done } = require('./stageb/boot');
const expected = [
const expected = [
{ kind: 'host.deploy', id: readyDeploy.id }, { kind: 'host.deploy', id: failedDeploy.id },
{ kind: 'host.deploy', id: readyDeploy.id }, { kind: 'host.deploy', id: failedDeploy.id },
{ kind: 'host.domain', id: customDomain.id },
{ kind: 'host.domain', id: customDomain.id },
- { kind: 'host.site', id: siteA1.id }, { kind: 'host.site', id: siteA2.id }, { kind: 'host.site', id: siteB.id },
+ { kind: 'host.site', id: siteA1.id }, { kind: 'host.site', id: siteA2.id }, { kind: 'host.site', id: siteB.id }, { kind: 'host.site', id: siteC.id },
];
];
const byId = new Map(page.resources.map((r) => [r.id, r]));
const byId = new Map(page.resources.map((r) => [r.id, r]));
for (const d of defaultDomains) expected.push({ kind: 'host.domain', id: d.id });
for (const d of defaultDomains) expected.push({ kind: 'host.domain', id: d.id });
@@ -127,16 +130,45 @@ const { boot, check, done } = require('./stageb/boot');
@@ -127,16 +130,45 @@ const { boot, check, done } = require('./stageb/boot');
await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {
await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {
const sites = await resourcesOf('?kind=host.site', auth);
const sites = await resourcesOf('?kind=host.site', auth);
- assert.deepStrictEqual(sites.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id, siteB.id].sort());
+ assert.deepStrictEqual(sites.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id, siteB.id, siteC.id].sort());
const deploys = await resourcesOf('?kind=host.deploy', auth);
const deploys = await resourcesOf('?kind=host.deploy', auth);
assert.deepStrictEqual(deploys.resources.map((r) => r.id).sort(), [readyDeploy.id, failedDeploy.id].sort());
assert.deepStrictEqual(deploys.resources.map((r) => r.id).sort(), [readyDeploy.id, failedDeploy.id].sort());
const domains = await resourcesOf('?kind=host.domain', auth);
const domains = await resourcesOf('?kind=host.domain', auth);
- assert.strictEqual(domains.resources.length, 4, "three default domains and A's custom one");
+ assert.strictEqual(domains.resources.length, 5, "four default domains and A's custom one");
const scoped = await resourcesOf(`?project=${prjA}&kind=host.site`, auth);
const scoped = await resourcesOf(`?project=${prjA}&kind=host.site`, auth);
assert.deepStrictEqual(scoped.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id].sort(), 'kind narrows within the project');
assert.deepStrictEqual(scoped.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id].sort(), 'kind narrows within the project');
assert.deepStrictEqual((await resourcesOf('?kind=host.unknown', auth)).resources, []);
assert.deepStrictEqual((await resourcesOf('?kind=host.unknown', auth)).resources, []);
});
});
+ await check('?owner= filters every kind and combines with project, kind and cursor', async () => {
+ const all = (await resourcesOf('', auth)).resources;
+ const aliceRows = all.filter((r) => r.owner && r.owner.id === alice.subject);
+ const bobRows = all.filter((r) => r.owner && r.owner.id === bob.subject);
+ assert.deepStrictEqual(bobRows.map((r) => r.id).sort(), [siteC.id, defaultDomains.find((d) => d.site_id === siteC.id).id].sort());
+ const own = await resourcesOf(`?owner=${alice.subject}`, auth);
+ assert.deepStrictEqual(own.resources, aliceRows, 'only Alice-owned summaries in index order');
+ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}`, auth)).resources, bobRows, 'Bob owns a site and its default domain');
+ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&kind=host.site&project=${prjA}`, auth)).resources,
+ aliceRows.filter((r) => r.kind === 'host.site' && r.project_id === prjA));
+ assert.deepStrictEqual((await resourcesOf(`?owner=${bob.subject}&project=${prjA}`, auth)).resources, []);
+ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject}&project=${prjC}`, auth)).resources, []);
+ assert.deepStrictEqual((await resourcesOf(`?owner=${alice.subject.replace(/^usr_/, 'agt_')}`, auth)).resources, []);
+ assert.deepStrictEqual((await resourcesOf('?owner=', auth)).resources, all, 'empty owner means no filter');
+
+ const seen = [];
+ let cursor = null;
+ let pages = 0;
+ for (;;) {
+ const page = await resourcesOf(`?owner=${alice.subject}&limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);
+ assert.ok(page.resources.length <= 2);
+ seen.push(...page.resources);
+ if (page.next_cursor === null) break;
+ cursor = page.next_cursor;
+ if (++pages > 50) assert.fail('the owner cursor chain never ended');
+ }
+ assert.deepStrictEqual(seen, aliceRows, 'owner paging has no duplicates or gaps');
+ });
+
await check('the cursor pages the index without duplicates, gaps or reordering', async () => {
await check('the cursor pages the index without duplicates, gaps or reordering', async () => {
const whole = (await resourcesOf('', auth)).resources.map((r) => r.id);
const whole = (await resourcesOf('', auth)).resources.map((r) => r.id);
const seen = [];
const seen = [];
@@ -180,7 +212,7 @@ const { boot, check, done } = require('./stageb/boot');
@@ -180,7 +212,7 @@ const { boot, check, done } = require('./stageb/boot');
});
});
await check('a query that cannot be honoured is 400 resources.bad_query', async () => {
await check('a query that cannot be honoured is 400 resources.bad_query', async () => {
- 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 not a subject id'], ['?owner=usr_short', 'owner 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']];
for (const [q, why] of bad) {
for (const [q, why] of bad) {
const r = await t.api('GET', `/api/v1/resources${q}`, auth);
const r = await t.api('GET', `/api/v1/resources${q}`, auth);
assert.strictEqual(r.status, 400, why);
assert.strictEqual(r.status, 400, why);