185 lines
6.8 KiB
JavaScript
185 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
// S3: admin access-scopes SoT = directory_grants (full grant table), same as access-policy.
|
||
|
|
// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1.
|
||
|
|
|
||
|
|
"use strict";
|
||
|
|
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const os = require("node:os");
|
||
|
|
const path = require("node:path");
|
||
|
|
|
||
|
|
const BASE = process.env.MNOTE_S3_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||
|
|
const ACTOR_ID = process.env.MNOTE_S3_ACTOR_ID || "s3-grants-admin";
|
||
|
|
const OTHER_ID = process.env.MNOTE_S3_OTHER_ID || "s3-grants-other-user";
|
||
|
|
const AUTH = process.env.MNOTE_S3_AUTH || "Bearer s3-grants";
|
||
|
|
const ROOT =
|
||
|
|
process.env.MNOTE_S3_ROOT ||
|
||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-"));
|
||
|
|
const OTHER_ROOT =
|
||
|
|
process.env.MNOTE_S3_OTHER_ROOT ||
|
||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-other-"));
|
||
|
|
|
||
|
|
function assert(condition, message) {
|
||
|
|
if (!condition) throw new Error(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchJson(url, options = {}) {
|
||
|
|
const headers = {
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
Authorization: AUTH,
|
||
|
|
"x-mnote-actor-id": ACTOR_ID,
|
||
|
|
"x-mnote-actor-type": "admin",
|
||
|
|
...(options.headers || {}),
|
||
|
|
};
|
||
|
|
const res = await fetch(url, { ...options, headers });
|
||
|
|
const text = await res.text();
|
||
|
|
let body = {};
|
||
|
|
try {
|
||
|
|
body = text ? JSON.parse(text) : {};
|
||
|
|
} catch {
|
||
|
|
body = { raw: text };
|
||
|
|
}
|
||
|
|
return { status: res.status, body };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function seed() {
|
||
|
|
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
seeds: [
|
||
|
|
{
|
||
|
|
kind: "setupWorkspace",
|
||
|
|
user_id: ACTOR_ID,
|
||
|
|
email: `${ACTOR_ID}@example.com`,
|
||
|
|
username: ACTOR_ID,
|
||
|
|
display_name: ACTOR_ID,
|
||
|
|
role: "admin",
|
||
|
|
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||
|
|
workspace_id: `local-ws:${ACTOR_ID}:s3`,
|
||
|
|
workspace_name: "S3 grants admin",
|
||
|
|
root_uri: `file://${ROOT}`,
|
||
|
|
root_path: ROOT,
|
||
|
|
source_kind: "local_folder",
|
||
|
|
permission: "write",
|
||
|
|
capabilities: ["ai"],
|
||
|
|
grant_source: "s3_grants_smoke",
|
||
|
|
grant_created_by: ACTOR_ID,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
kind: "setupWorkspace",
|
||
|
|
user_id: OTHER_ID,
|
||
|
|
email: `${OTHER_ID}@example.com`,
|
||
|
|
username: OTHER_ID,
|
||
|
|
display_name: OTHER_ID,
|
||
|
|
role: "user",
|
||
|
|
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||
|
|
workspace_id: `local-ws:${OTHER_ID}:s3`,
|
||
|
|
workspace_name: "S3 grants other",
|
||
|
|
root_uri: `file://${OTHER_ROOT}`,
|
||
|
|
root_path: OTHER_ROOT,
|
||
|
|
source_kind: "local_folder",
|
||
|
|
permission: "write",
|
||
|
|
capabilities: ["ai"],
|
||
|
|
grant_source: "s3_grants_smoke_other",
|
||
|
|
grant_created_by: ACTOR_ID,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||
|
|
throw new Error(
|
||
|
|
"S3 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
assert(
|
||
|
|
seed.status === 200 && seed.body.ok === true,
|
||
|
|
`/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function rootUris(allowedRoots) {
|
||
|
|
return (Array.isArray(allowedRoots) ? allowedRoots : [])
|
||
|
|
.map((r) => String(r.rootUri || r.root_uri || ""))
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
function userIds(allowedRoots) {
|
||
|
|
return (Array.isArray(allowedRoots) ? allowedRoots : [])
|
||
|
|
.map((r) => String(r.userId || r.user_id || ""))
|
||
|
|
.filter(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
fs.mkdirSync(ROOT, { recursive: true });
|
||
|
|
fs.mkdirSync(OTHER_ROOT, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(ROOT, "s3.md"), "# S3 grants\n", "utf8");
|
||
|
|
fs.writeFileSync(path.join(OTHER_ROOT, "other.md"), "# other\n", "utf8");
|
||
|
|
|
||
|
|
console.log(`\n🧪 S3 directory grants ≡ allowed roots (base=${BASE})\n`);
|
||
|
|
|
||
|
|
await seed();
|
||
|
|
|
||
|
|
const adminScopes = await fetchJson(`${BASE}/api/ai-admin/access-scopes`);
|
||
|
|
assert(
|
||
|
|
adminScopes.status === 200,
|
||
|
|
`admin access-scopes: ${adminScopes.status} ${JSON.stringify(adminScopes.body)}`,
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
String(adminScopes.body.sourceOfTruth || adminScopes.body.source_of_truth) === "directory_grants",
|
||
|
|
`sourceOfTruth expected directory_grants, got ${adminScopes.body.sourceOfTruth}`,
|
||
|
|
);
|
||
|
|
const adminUris = rootUris(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots);
|
||
|
|
const adminUsers = userIds(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots);
|
||
|
|
console.log(` admin scopes: ${adminUris.length} roots, users=${[...new Set(adminUsers)].join(",")}`);
|
||
|
|
|
||
|
|
// Admin SoT must include both admin actor grant and other user's grant (full table).
|
||
|
|
assert(
|
||
|
|
adminUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`),
|
||
|
|
`admin scopes missing admin root: ${JSON.stringify(adminUris)}`,
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
adminUris.some((u) => u.includes(OTHER_ROOT) || u === `file://${OTHER_ROOT}`),
|
||
|
|
`admin scopes missing OTHER user grant (must be full grant table, not actor-scoped): ${JSON.stringify(adminUris)}`,
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
adminUsers.includes(OTHER_ID) || adminUsers.some((u) => u.includes("s3-grants-other")),
|
||
|
|
`admin scopes should list other user_id: ${JSON.stringify(adminUsers)}`,
|
||
|
|
);
|
||
|
|
|
||
|
|
// User-facing scopes for admin actor should be actor-scoped (own grants only).
|
||
|
|
const userScopes = await fetchJson(`${BASE}/api/ai-settings/access-scopes`);
|
||
|
|
assert(userScopes.status === 200, `user access-scopes: ${userScopes.status}`);
|
||
|
|
assert(
|
||
|
|
String(userScopes.body.sourceOfTruth || userScopes.body.source_of_truth) === "directory_grants",
|
||
|
|
`user sourceOfTruth expected directory_grants`,
|
||
|
|
);
|
||
|
|
const userUris = rootUris(userScopes.body.allowedRoots || userScopes.body.allowed_roots);
|
||
|
|
assert(
|
||
|
|
userUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`),
|
||
|
|
`user scopes missing own root`,
|
||
|
|
);
|
||
|
|
// Own actor scopes should not include unrelated other-root as "mine" identity —
|
||
|
|
// but if system seeds extra grants for admin, still require admin endpoint ≥ user endpoint.
|
||
|
|
assert(
|
||
|
|
adminUris.length >= userUris.length,
|
||
|
|
`admin full table (${adminUris.length}) should cover at least actor scopes (${userUris.length})`,
|
||
|
|
);
|
||
|
|
console.log(` user (actor) scopes: ${userUris.length}; admin full: ${adminUris.length}`);
|
||
|
|
|
||
|
|
// Effective also declares directory_grants SoT.
|
||
|
|
const effective = await fetchJson(`${BASE}/api/ai-settings/effective`);
|
||
|
|
assert(effective.status === 200, `effective: ${effective.status}`);
|
||
|
|
assert(
|
||
|
|
String(effective.body.sourceOfTruth || effective.body.source_of_truth) === "directory_grants",
|
||
|
|
`effective sourceOfTruth: ${effective.body.sourceOfTruth}`,
|
||
|
|
);
|
||
|
|
console.log(" effective.sourceOfTruth=directory_grants");
|
||
|
|
|
||
|
|
console.log("\n✅ S3 directory grants ≡ allowed roots smoke passed\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((err) => {
|
||
|
|
console.error("\n❌ S3 smoke failed:", err.message || err);
|
||
|
|
process.exit(1);
|
||
|
|
});
|