|
|
@@ -0,0 +1,253 @@ |
| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 7 |
import { mkdtemp, rm } from "node:fs/promises"; |
| 8 |
import { tmpdir } from "node:os"; |
| 9 |
import { join } from "node:path"; |
| 10 |
import { createApp, type App } from "../src/app.ts"; |
| 11 |
import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 12 |
import { openIssue } from "../src/actions/issues.ts"; |
| 13 |
import { createRepo } from "../src/actions/repos.ts"; |
| 14 |
import { listComments } from "../src/core/issues.ts"; |
| 15 |
import { recordInteraction } from "../src/core/interactions.ts"; |
| 16 |
import type { Repo } from "../src/core/repos.ts"; |
| 17 |
import { pin } from "../src/core/pins.ts"; |
| 18 |
import { DAILY_BUDGET, QUEUE_TTL_MS, senderWeight } from "../src/core/shares.ts"; |
| 19 |
import { createToken } from "../src/core/tokens.ts"; |
| 20 |
import { createUser, type User } from "../src/core/users.ts"; |
| 21 |
import { readFeed } from "../src/feed/feed.ts"; |
| 22 |
import { share } from "../src/feed/share.ts"; |
| 23 |
import { parseTargets } from "../src/http/feed.ts"; |
| 24 |
import { serve, type Servers } from "../src/http/server.ts"; |
| 25 |
|
| 26 |
const HOUR = 3600_000; |
| 27 |
|
| 28 |
describe("share", () => { |
| 29 |
let dataDir: string; |
| 30 |
let app: App; |
| 31 |
let servers: Servers; |
| 32 |
let alice: User; |
| 33 |
let bob: User; |
| 34 |
let carol: User; |
| 35 |
let bot: User; |
| 36 |
let aliceToken: string; |
| 37 |
let open: Repo; |
| 38 |
let secret: Repo; |
| 39 |
let n = 0; |
| 40 |
const emit = (repo: Repo, kind = "run.finished", payload: object = {}) => |
| 41 |
`event:${app.bus.append({ |
| 42 |
kind, |
| 43 |
repoId: repo.id, |
| 44 |
actorId: alice.id, |
| 45 |
payload: { kind: "deploy", state: "ok", summary: `run ${++n}`, runId: `r${n}`, ...payload }, |
| 46 |
}).id}`; |
| 47 |
|
| 48 |
beforeAll(async () => { |
| 49 |
dataDir = await mkdtemp(join(tmpdir(), "forge-share-")); |
| 50 |
app = createApp({ dataDir, port: 0 }); |
| 51 |
servers = await serve(app); |
| 52 |
const mk = async (username: string, first = false) => { |
| 53 |
const r = await createUser(app.db, { |
| 54 |
username, |
| 55 |
email: `${username}@forge.local`, |
| 56 |
password: "a-good-passphrase", |
| 57 |
adminIfFirst: first, |
| 58 |
}); |
| 59 |
if (!r.ok) throw new Error(r.error); |
| 60 |
return r.value; |
| 61 |
}; |
| 62 |
const root = await mk("root", true); |
| 63 |
alice = await mk("alice"); |
| 64 |
bob = await mk("bob"); |
| 65 |
carol = await mk("carol"); |
| 66 |
const made = await createAgent(app, root, { username: "bot", brief: "Be brief." }); |
| 67 |
if (!made.ok) throw new Error(String(made.error)); |
| 68 |
bot = made.value.user; |
| 69 |
aliceToken = createToken(app.db, alice.id, "t", "rw").token; |
| 70 |
const o = await createRepo(app, alice, { name: "open", visibility: "public" }); |
| 71 |
const s = await createRepo(app, alice, { name: "secret", visibility: "private" }); |
| 72 |
if (!o.ok || !s.ok) throw new Error("repos"); |
| 73 |
open = o.value; |
| 74 |
secret = s.value; |
| 75 |
expect(addToCrew(app, root, open, "bot").ok).toBe(true); |
| 76 |
}); |
| 77 |
|
| 78 |
afterAll(async () => { |
| 79 |
servers.stop(); |
| 80 |
app.close(); |
| 81 |
await rm(dataDir, { recursive: true, force: true }); |
| 82 |
}); |
| 83 |
|
| 84 |
const sent = async (item: string, to: string[], who = alice, now?: number) => { |
| 85 |
const r = await share(app, who, item, to, now); |
| 86 |
if (!r.ok) throw new Error(r.error); |
| 87 |
return r.value.sent; |
| 88 |
}; |
| 89 |
|
| 90 |
test("a share never widens access", async () => { |
| 91 |
const item = emit(secret); |
| 92 |
|
| 93 |
expect(await sent(item, ["bob"])).toEqual([{ to: "bob", outcome: "unreachable" }]); |
| 94 |
expect(readFeed(app.db, bob).items.some((i) => i.app === "alice/secret")).toBe(false); |
| 95 |
|
| 96 |
expect(await sent(item, ["nobody"])).toEqual([{ to: "nobody", outcome: "unreachable" }]); |
| 97 |
|
| 98 |
const r = await share(app, bob, item, ["carol"]); |
| 99 |
expect(r).toEqual({ ok: false, error: "not_found" }); |
| 100 |
}); |
| 101 |
|
| 102 |
test("a person gets a boosted item that says who shared it, once", async () => { |
| 103 |
const item = emit(open); |
| 104 |
expect(await sent(item, ["bob"])).toEqual([{ to: "bob", outcome: "delivered" }]); |
| 105 |
const items = readFeed(app.db, bob).items; |
| 106 |
const hit = items.find((i) => i.id.startsWith("share:")); |
| 107 |
expect(hit?.reason).toStartWith("shared by alice: "); |
| 108 |
expect(items.some((i) => i.id === item)).toBe(false); |
| 109 |
expect(items[0]?.id).toBe(hit!.id); |
| 110 |
|
| 111 |
expect(await sent(item, ["bob"])).toEqual([{ to: "bob", outcome: "duplicate" }]); |
| 112 |
expect(readFeed(app.db, bob).items.filter((i) => i.id.startsWith("share:")).length).toBe(1); |
| 113 |
}); |
| 114 |
|
| 115 |
test("the daily budget queues the rest, and the queue decays", async () => { |
| 116 |
const t0 = Date.now(); |
| 117 |
const items = Array.from({ length: DAILY_BUDGET + 2 }, () => emit(open)); |
| 118 |
const outcomes: string[] = []; |
| 119 |
for (const it of items) outcomes.push((await sent(it, ["carol"], alice, t0))[0]!.outcome); |
| 120 |
expect(outcomes.filter((o) => o === "delivered").length).toBe(DAILY_BUDGET); |
| 121 |
expect(outcomes.filter((o) => o === "queued").length).toBe(2); |
| 122 |
const shares = (now: number) => |
| 123 |
readFeed(app.db, carol, { now, limit: 50 }).items.filter((i) => i.id.startsWith("share:")).length; |
| 124 |
expect(shares(t0 + HOUR)).toBe(DAILY_BUDGET); |
| 125 |
|
| 126 |
expect(shares(t0 + 25 * HOUR)).toBe(DAILY_BUDGET + 2); |
| 127 |
}); |
| 128 |
|
| 129 |
test("a queued share that waits too long is dropped", async () => { |
| 130 |
const t0 = Date.now(); |
| 131 |
const dave = await createUser(app.db, { |
| 132 |
username: "dave", |
| 133 |
email: "[email protected]", |
| 134 |
password: "a-good-passphrase", |
| 135 |
}); |
| 136 |
if (!dave.ok) throw new Error(dave.error); |
| 137 |
|
| 138 |
for (let i = 0; i < DAILY_BUDGET; i++) await sent(emit(open), ["dave"], alice, t0 + 40 * HOUR); |
| 139 |
const late = await sent(emit(open), ["dave"], alice, t0 + 40 * HOUR); |
| 140 |
expect(late[0]!.outcome).toBe("queued"); |
| 141 |
|
| 142 |
const way = t0 + 40 * HOUR + QUEUE_TTL_MS + HOUR; |
| 143 |
const feed = readFeed(app.db, dave.value, { now: way, limit: 50 }); |
| 144 |
expect(feed.items.filter((i) => i.id.startsWith("share:")).length).toBe(DAILY_BUDGET); |
| 145 |
const state = app.db |
| 146 |
.query<{ state: string }, [string]>("SELECT state FROM share WHERE recipient_id = ?1 AND state = 'dropped'") |
| 147 |
.all(dave.value.id); |
| 148 |
expect(state.length).toBe(1); |
| 149 |
}); |
| 150 |
|
| 151 |
test("muting the app is a strong no", async () => { |
| 152 |
recordInteraction(app.db, { |
| 153 |
viewerId: bob.id, |
| 154 |
itemId: "app:x", |
| 155 |
kind: "muted", |
| 156 |
repoId: open.id, |
| 157 |
actorId: null, |
| 158 |
}); |
| 159 |
expect(await sent(emit(open), ["bob"])).toEqual([{ to: "bob", outcome: "skipped" }]); |
| 160 |
}); |
| 161 |
|
| 162 |
test("an agent is steered on a change, told otherwise", async () => { |
| 163 |
const issue = openIssue(app, alice, open, { title: "Plant tulips", body: "" }); |
| 164 |
if (!issue.ok) throw new Error(String(issue.error)); |
| 165 |
const change = emit(open, "pull.opened", { number: issue.value.number }); |
| 166 |
expect(await sent(change, ["bot"])).toEqual([{ to: "bot", outcome: "steered" }]); |
| 167 |
const said = listComments(app.db, issue.value.id).map((c) => c.body); |
| 168 |
expect(said.some((b) => b.startsWith("Shared by alice with bot:"))).toBe(true); |
| 169 |
|
| 170 |
expect(await sent(change, ["bot"])).toEqual([{ to: "bot", outcome: "duplicate" }]); |
| 171 |
const plain = emit(open); |
| 172 |
expect(await sent(plain, ["bot"])).toEqual([{ to: "bot", outcome: "told" }]); |
| 173 |
}); |
| 174 |
|
| 175 |
test("with no target it goes to crew and owners, then followers", async () => { |
| 176 |
const item = emit(open); |
| 177 |
|
| 178 |
expect((await sent(item, [])).map((s) => s.to)).toEqual(["bot"]); |
| 179 |
|
| 180 |
const item2 = emit(open); |
| 181 |
expect((await sent(item2, [], bob)).map((s) => s.to).sort()).toEqual(["alice", "bot"]); |
| 182 |
|
| 183 |
const lone = await createRepo(app, alice, { name: "lone", visibility: "public" }); |
| 184 |
if (!lone.ok) throw new Error("repo"); |
| 185 |
const erin = await createUser(app.db, { username: "erin", email: "[email protected]", password: "a-good-passphrase" }); |
| 186 |
if (!erin.ok) throw new Error("user"); |
| 187 |
pin(app.db, erin.value.id, lone.value.id); |
| 188 |
const item3 = emit(lone.value); |
| 189 |
expect(await sent(item3, [])).toEqual([{ to: "erin", outcome: "delivered" }]); |
| 190 |
}); |
| 191 |
|
| 192 |
test("a sender who is opened rises, one who is not sinks", async () => { |
| 193 |
const mk = async (username: string) => { |
| 194 |
const r = await createUser(app.db, { username, email: `${username}@forge.local`, password: "a-good-passphrase" }); |
| 195 |
if (!r.ok) throw new Error("user"); |
| 196 |
return r.value; |
| 197 |
}; |
| 198 |
const sender = await mk("sender"); |
| 199 |
const reader = await mk("reader"); |
| 200 |
expect(senderWeight(app.db, sender.id, Date.now())).toBe(1); |
| 201 |
const items = [] as string[]; |
| 202 |
for (let i = 0; i < 4; i++) { |
| 203 |
items.push(emit(open)); |
| 204 |
await sent(items[i]!, ["reader"], sender); |
| 205 |
} |
| 206 |
const ignored = senderWeight(app.db, sender.id, Date.now()); |
| 207 |
expect(ignored).toBeLessThan(0.5); |
| 208 |
for (const it of items) |
| 209 |
recordInteraction(app.db, { viewerId: reader.id, itemId: it, kind: "opened", repoId: open.id, actorId: null }); |
| 210 |
expect(senderWeight(app.db, sender.id, Date.now())).toBeGreaterThan(1.5); |
| 211 |
}); |
| 212 |
|
| 213 |
test("the route validates, checks scope and answers", async () => { |
| 214 |
const item = emit(open); |
| 215 |
const post = (token: string | undefined, payload: unknown) => |
| 216 |
fetch(`${servers.url}/api/v1/share`, { |
| 217 |
method: "POST", |
| 218 |
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "content-type": "application/json" }, |
| 219 |
body: JSON.stringify(payload), |
| 220 |
}); |
| 221 |
expect((await post(undefined, { item })).status).toBe(401); |
| 222 |
expect((await post(aliceToken, { item: "nope" })).status).toBe(422); |
| 223 |
expect((await post(aliceToken, { item, to: [1] })).status).toBe(422); |
| 224 |
expect((await post(aliceToken, { item, to: "bad name!" })).status).toBe(422); |
| 225 |
expect((await post(aliceToken, { item: "event:missing" })).status).toBe(404); |
| 226 |
const ok = await post(aliceToken, { item, to: ["carol"] }); |
| 227 |
expect(ok.status).toBe(201); |
| 228 |
expect(await ok.json()).toEqual({ sent: [{ to: "carol", outcome: expect.any(String) }] }); |
| 229 |
const ro = createToken(app.db, alice.id, "ro", "ro").token; |
| 230 |
expect((await post(ro, { item, to: ["carol"] })).status).toBe(403); |
| 231 |
}); |
| 232 |
|
| 233 |
test("parseTargets accepts names and refuses the rest", () => { |
| 234 |
expect(parseTargets(undefined)).toEqual([]); |
| 235 |
expect(parseTargets("@bob")).toEqual(["bob"]); |
| 236 |
expect(parseTargets(["a", "b"])).toEqual(["a", "b"]); |
| 237 |
expect(parseTargets({})).toBeUndefined(); |
| 238 |
expect(parseTargets(Array(11).fill("a"))).toBeUndefined(); |
| 239 |
}); |
| 240 |
|
| 241 |
test("it is a tool on the MCP door", async () => { |
| 242 |
const item = emit(open); |
| 243 |
const call = (name: string, args: object) => |
| 244 |
fetch(`${servers.url}/mcp`, { |
| 245 |
method: "POST", |
| 246 |
headers: { authorization: `Bearer ${aliceToken}`, "content-type": "application/json", accept: "application/json" }, |
| 247 |
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), |
| 248 |
}).then((r) => r.json() as Promise<{ result: { isError?: boolean; content: { text: string }[] } }>); |
| 249 |
const r = await call("share", { item, to: ["carol"] }); |
| 250 |
expect(r.result.isError).toBeFalsy(); |
| 251 |
expect(r.result.content[0]!.text).toContain("carol"); |
| 252 |
}); |
| 253 |
}); |