783b7f4101
A script's claude -p in a temp folder (the SG Flow eval runner strips the pane's env) had no workspace, so its spend reached nobody's Today, the Users page or a spend quota — ~$57 of a $74 day was missing from the status bar. sessions.account records the Linux account a session ran as (the hook's peer uid, else its folder for poller-made and old rows); a session no workspace owns is owned by that account's user — a member's to them, the host's to the admin ticked 'Owns this server's host account' (or the only admin). Workspace ownership still wins.
111 lines
4.4 KiB
JavaScript
111 lines
4.4 KiB
JavaScript
/**
|
|
* @file session-account.test.js
|
|
* @description A session no workspace owns is billed to the user of the Linux
|
|
* account it ran as (server/lib/session-account.js): a member's account to
|
|
* that member, the host's to the admin marked as its owner (or the only
|
|
* admin). A workspace's ownership always wins, a pushed session never gets an
|
|
* account, and the hook peer is stamped once.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "session-account-test-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
|
|
const { db } = require("../db");
|
|
const sa = require("../lib/session-account");
|
|
const scope = require("../lib/scope");
|
|
const { OWNED_SESSIONS } = require("../routes/users");
|
|
|
|
const own = (id) => {
|
|
const w = scope.ownSessionWhere({ user: { id } });
|
|
return db
|
|
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
|
|
.all(...w.params)
|
|
.map((r) => r.id);
|
|
};
|
|
|
|
before(() => {
|
|
const user = db.prepare(
|
|
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
|
);
|
|
user.run(1, "boss", "admin");
|
|
user.run(2, "dev", "developer");
|
|
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
|
|
const s = db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
|
|
);
|
|
s.run("host-free", "eval", null, "", "local");
|
|
s.run("dev-free", "eval", null, "dev", "local");
|
|
s.run("host-in-dev-lane", "lane", 1, "", "local");
|
|
s.run("unknown", "old", null, null, "local");
|
|
s.run("gone-member", "x", null, "ghost", "local");
|
|
});
|
|
|
|
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
|
|
describe("session-account", () => {
|
|
it("bills a member's account to that member and the host's to the only admin", () => {
|
|
assert.equal(sa.hostOwner(), 1);
|
|
assert.deepEqual(own(1), ["host-free"]);
|
|
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
|
|
});
|
|
|
|
it("lets the workspace's owner win over the account", () => {
|
|
assert.ok(!own(1).includes("host-in-dev-lane"));
|
|
});
|
|
|
|
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
|
|
db.prepare(
|
|
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
|
|
).run();
|
|
assert.equal(sa.hostOwner(), null);
|
|
assert.deepEqual(own(1), []);
|
|
sa.setHostOwner(3);
|
|
assert.equal(sa.hostOwner(), 3);
|
|
assert.deepEqual(own(3), ["host-free"]);
|
|
sa.setHostOwner(1);
|
|
assert.deepEqual(own(3), []);
|
|
assert.deepEqual(own(1), ["host-free"]);
|
|
});
|
|
|
|
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
|
|
const rows = db.prepare(OWNED_SESSIONS).all();
|
|
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
|
|
assert.equal(by["host-free"], 1);
|
|
assert.equal(by["dev-free"], 2);
|
|
assert.equal(by["host-in-dev-lane"], 2);
|
|
assert.equal(by.unknown, undefined);
|
|
assert.equal(by["gone-member"], undefined);
|
|
});
|
|
|
|
it("stamps the hook peer once and never a pushed session", () => {
|
|
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
|
|
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
|
|
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
|
|
sa.stampFromPeer("fresh", { kind: "host" });
|
|
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
|
|
sa.stampFromPeer("pushed", { kind: "refused" });
|
|
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
|
assert.equal(acct("fresh"), "dev");
|
|
assert.equal(acct("pushed"), null);
|
|
});
|
|
|
|
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
|
|
).run();
|
|
sa.stampFromFolders();
|
|
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
|
// Outside S3 every folder is the host's.
|
|
assert.equal(acct("unknown"), "");
|
|
assert.equal(acct("pushed"), "");
|
|
assert.equal(acct("remote"), null);
|
|
});
|
|
});
|