working load client actions

This commit is contained in:
zk
2026-03-19 21:11:30 +00:00
parent c80e2ea2ef
commit 19ade534fd
11 changed files with 307 additions and 53 deletions
-7
View File
@@ -43,11 +43,4 @@
"eslint-config-tachi": "workspace:*",
"typescript": "catalog:"
},
"imports": {
"#*": [
"./src/*",
"./src/*.ts",
"./src/*/index.ts"
]
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { AppendLogCtx, log as baseLogger } from "#log.js";
import { AppendLogCtx, log as baseLogger } from "./log.js";
import { type ZodObject } from "zod";
export type ActionResult = "BAD" | "GOOD" | "THROW";
+2 -8
View File
@@ -1,9 +1,3 @@
export {
AcctInfo,
ActionResult,
ActionTaker,
AnonActionTaker,
ExpectedErr,
MakeActionGuts,
} from "./actions";
export type { AcctInfo, ActionResult, ActionTaker, AnonActionTaker } from "./actions";
export { ExpectedErr, MakeActionGuts } from "./actions";
export * from "./log";
-3
View File
@@ -3,9 +3,6 @@
"compilerOptions": {
"module": "Preserve",
"moduleResolution": "bundler",
"paths": {
"#*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
-7
View File
@@ -60,11 +60,4 @@
"src/external/mongo/schemas.ts"
]
},
"imports": {
"#*": [
"./src/*",
"./src/*.ts",
"./src/*/index.ts"
]
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { ALL_PERMISSIONS } from "#constants/permissions.js";
import z from "zod";
import { ALL_PERMISSIONS } from "../constants/permissions";
export const zodPermission = z.enum(Object.keys(ALL_PERMISSIONS));
-3
View File
@@ -3,9 +3,6 @@
"compilerOptions": {
"module": "Preserve",
"moduleResolution": "bundler",
"paths": {
"#*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
@@ -0,0 +1,254 @@
import DB from "#services/pg/db.js";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_InstallBuiltinClient } from "./install-builtin-client.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function seedUser(username: string, authLevel: "admin" | "user" = "user") {
const { id } = await DB.insertInto("account")
.values({ username, about: "Test user.", auth_level: authLevel })
.returning("id")
.executeTakeFirstOrThrow();
return { id: Number(id), username };
}
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const BASE_PERMISSIONS = {
customise_profile: false,
customise_score: false,
customise_session: false,
delete_score: false,
manage_rivals: false,
manage_targets: false,
submit_score: true,
manage_challenges: false,
};
const BASE_INPUT = {
clientID: "CXTestClient",
name: "Test Client",
permissions: BASE_PERMISSIONS,
apiKeyFilename: "test-client.json",
apiKeyTemplate: '{"token": "%%TACHI_KEY%%"}',
webhookUri: null,
redirectUri: null,
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ACTION_InstallBuiltinClient", () => {
let adminId: number;
let userId: number;
beforeEach(async () => {
const admin = await seedUser("admin_user", "admin");
adminId = admin.id;
const user = await seedUser("regular_user", "user");
userId = user.id;
});
// ── Authorization ──────────────────────────────────────────────────────────
it("throws with code 403 when the taker is not an admin", async () => {
const taker = { ip: null, acct: { id: userId, username: "regular_user" } };
await expect(ACTION_InstallBuiltinClient(taker, BASE_INPUT)).rejects.toMatchObject({
code: 403,
});
});
it("writes a BAD action row when the taker is not an admin", async () => {
const taker = { ip: null, acct: { id: userId, username: "regular_user" } };
await expect(ACTION_InstallBuiltinClient(taker, BASE_INPUT)).rejects.toThrow();
const action = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "INSTALL_BUILTIN_CLIENT")
.executeTakeFirstOrThrow();
expect(action.result).toBe("BAD");
});
it("does not insert a client when the taker is not an admin", async () => {
const taker = { ip: null, acct: { id: userId, username: "regular_user" } };
await expect(ACTION_InstallBuiltinClient(taker, BASE_INPUT)).rejects.toThrow();
const clients = await DB.selectFrom("priv_api_client")
.select("client_id")
.where("client_id", "=", BASE_INPUT.clientID)
.execute();
expect(clients).toHaveLength(0);
});
// ── Success path ───────────────────────────────────────────────────────────
it("returns an empty object on success", async () => {
const taker = { ip: null, acct: { id: adminId, username: "admin_user" } };
const result = await ACTION_InstallBuiltinClient(taker, BASE_INPUT);
expect(result).toEqual({});
});
it("inserts the client with the correct fields", async () => {
const taker = { ip: null, acct: { id: adminId, username: "admin_user" } };
await ACTION_InstallBuiltinClient(taker, BASE_INPUT);
const client = await DB.selectFrom("priv_api_client")
.selectAll()
.where("client_id", "=", BASE_INPUT.clientID)
.executeTakeFirst();
expect(client).toMatchObject({
client_id: "CXTestClient",
name: "Test Client",
author: String(adminId),
pm_submit_score: true,
pm_customise_profile: false,
pm_customise_score: false,
pm_customise_session: false,
pm_delete_score: false,
pm_manage_rivals: false,
pm_manage_targets: false,
pm_manage_challenges: false,
api_key_filename: "test-client.json",
api_key_template: '{"token": "%%TACHI_KEY%%"}',
webhook_uri: null,
redirect_uri: null,
});
});
it("always marks the client as is_builtin: true", async () => {
const taker = { ip: null, acct: { id: adminId, username: "admin_user" } };
await ACTION_InstallBuiltinClient(taker, BASE_INPUT);
const client = await DB.selectFrom("priv_api_client")
.select("is_builtin")
.where("client_id", "=", BASE_INPUT.clientID)
.executeTakeFirstOrThrow();
expect(client.is_builtin).toBe(true);
});
it("generates a non-empty client secret", async () => {
const taker = { ip: null, acct: { id: adminId, username: "admin_user" } };
await ACTION_InstallBuiltinClient(taker, BASE_INPUT);
const client = await DB.selectFrom("priv_api_client")
.select("client_secret")
.where("client_id", "=", BASE_INPUT.clientID)
.executeTakeFirstOrThrow();
expect(client.client_secret).toMatch(/^CS[0-9a-f]{40}$/u);
});
it("stores null for optional fields when they are not provided", async () => {
const taker = { ip: null, acct: { id: adminId, username: "admin_user" } };
await ACTION_InstallBuiltinClient(taker, {
...BASE_INPUT,
apiKeyFilename: null,
apiKeyTemplate: null,
webhookUri: null,
redirectUri: null,
});
const client = await DB.selectFrom("priv_api_client")
.selectAll()
.where("client_id", "=", BASE_INPUT.clientID)
.executeTakeFirstOrThrow();
expect(client.api_key_filename).toBeNull();
expect(client.api_key_template).toBeNull();
expect(client.webhook_uri).toBeNull();
expect(client.redirect_uri).toBeNull();
});
// ── Upsert behaviour ───────────────────────────────────────────────────────
it("updates the existing row when called again with the same clientID", async () => {
const taker = { ip: null, acct: { id: adminId, username: "admin_user" } };
await ACTION_InstallBuiltinClient(taker, BASE_INPUT);
await ACTION_InstallBuiltinClient(taker, { ...BASE_INPUT, name: "Updated Name" });
const clients = await DB.selectFrom("priv_api_client")
.select(["client_id", "name"])
.where("client_id", "=", BASE_INPUT.clientID)
.execute();
expect(clients).toHaveLength(1);
expect(clients[0]?.name).toBe("Updated Name");
});
it("uses the new author on upsert", async () => {
const first = await seedUser("first_admin", "admin");
const second = await seedUser("second_admin", "admin");
await ACTION_InstallBuiltinClient(
{ ip: null, acct: { id: first.id, username: first.username } },
BASE_INPUT,
);
await ACTION_InstallBuiltinClient(
{ ip: null, acct: { id: second.id, username: second.username } },
BASE_INPUT,
);
const client = await DB.selectFrom("priv_api_client")
.select("author")
.where("client_id", "=", BASE_INPUT.clientID)
.executeTakeFirstOrThrow();
expect(client.author).toBe(String(second.id));
});
// ── Audit log ──────────────────────────────────────────────────────────────
it("writes a GOOD action row to the audit log on success", async () => {
const taker = { ip: "10.0.0.1", acct: { id: adminId, username: "admin_user" } };
await ACTION_InstallBuiltinClient(taker, BASE_INPUT);
const action = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "INSTALL_BUILTIN_CLIENT")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({
app: "TACHI_SERVER",
kind: "INSTALL_BUILTIN_CLIENT",
result: "GOOD",
user_id: String(adminId),
ip: "10.0.0.1",
});
});
it("includes the client details in the audit log input", async () => {
const taker = { ip: null, acct: { id: adminId, username: "admin_user" } };
await ACTION_InstallBuiltinClient(taker, BASE_INPUT);
const action = await DB.selectFrom("action")
.select("input")
.where("kind", "=", "INSTALL_BUILTIN_CLIENT")
.executeTakeFirstOrThrow();
const input = action.input as Record<string, unknown>;
expect(input).toMatchObject({
clientID: BASE_INPUT.clientID,
name: BASE_INPUT.name,
});
});
});
@@ -40,8 +40,8 @@ export const ACTION_InstallBuiltinClient = MakeAction(
redirect_uri: redirectUri,
is_builtin: true,
})
.onConflict((oc) =>
oc.doUpdateSet({
.onConflict((oc) =>
oc.column("client_id").doUpdateSet({
client_secret: clientSecret,
name: name,
author: taker.acct.id,
@@ -2,7 +2,7 @@ import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client.js"
import { log } from "#lib/log/log.js";
import { ServerConfig, TachiConfig } from "#lib/setup/config";
import _ from "lodash";
import { type TachiAPIClientDocument } from "tachi-common";
import { type APIPermissions, type TachiAPIClientDocument } from "tachi-common";
/* eslint-disable no-await-in-loop */
import { GetClientByID } from "#utils/queries/api-clients.js";
import { GetFirstAdmin } from "#utils/user.js";
@@ -319,6 +319,11 @@ async function LoadClients(clients: DefaultClients) {
}
}
const permissionsObject: Partial<Record<APIPermissions, boolean>> = {};
for (const permission of client.requestedPermissions) {
permissionsObject[permission] = true;
}
await ACTION_InstallBuiltinClient(
{
ip: null,
@@ -330,16 +335,7 @@ async function LoadClients(clients: DefaultClients) {
{
clientID: client.clientID,
name: client.name,
permissions: {
customise_profile: false,
customise_score: false,
customise_session: false,
delete_score: false,
manage_rivals: false,
manage_targets: false,
submit_score: false,
manage_challenges: false,
},
permissions: permissionsObject,
apiKeyFilename: client.apiKeyFilename,
apiKeyTemplate: client.apiKeyTemplate,
redirectUri: client.redirectUri,
+39 -10
View File
@@ -1,23 +1,52 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import { defineConfig, type Plugin } from "vitest/config";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Read the test server config as a raw string — config.ts will JSON5-parse it at load time.
const tachiConfig = fs.readFileSync(path.join(__dirname, "test.conf.json5"), "utf-8");
export default defineConfig({
resolve: {
// Map #* path aliases to src/* so vite-node resolves them correctly.
alias: [
{
find: /^#(.+)/u,
replacement: `${path.resolve(__dirname, "src")}/$1`,
},
],
// Workspace root: /tachi/typescript/
const TYPESCRIPT_ROOT = path.resolve(__dirname, "..");
/**
* Resolve '#*' package-level imports (Node.js 'imports' field) correctly for
* every workspace package. Without this, vitest's alias applies the server's
* src/ root to '#*' imports made inside other workspace packages (e.g. bliss,
* tachi-common), causing "Cannot find module" errors.
*
* Strategy: derive the package root from the importer's absolute path, then
* resolve the specifier relative to that package's src/ directory.
*/
const workspaceHashAliasPlugin: Plugin = {
name: "workspace-hash-alias",
resolveId(source, importer) {
if (!source.startsWith("#")) {
return null;
}
const specifier = source.slice(1);
// When the importer is inside /tachi/typescript/<pkg>/…, resolve
// '#specifier' relative to /tachi/typescript/<pkg>/src/.
if (importer) {
const rel = path.relative(TYPESCRIPT_ROOT, importer);
if (!rel.startsWith("..")) {
const pkgName = rel.split(path.sep)[0];
return path.join(TYPESCRIPT_ROOT, pkgName, "src", specifier);
}
}
// Fallback: resolve relative to the server's own src/.
return path.join(__dirname, "src", specifier);
},
};
export default defineConfig({
plugins: [workspaceHashAliasPlugin],
test: {
// Static env vars. POSTGRES_URL is set dynamically per-worker in vitest.setup.ts