mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-11 08:48:15 +03:00
working load client actions
This commit is contained in:
@@ -43,11 +43,4 @@
|
||||
"eslint-config-tachi": "workspace:*",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"imports": {
|
||||
"#*": [
|
||||
"./src/*",
|
||||
"./src/*.ts",
|
||||
"./src/*/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,9 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"#*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -60,11 +60,4 @@
|
||||
"src/external/mongo/schemas.ts"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"#*": [
|
||||
"./src/*",
|
||||
"./src/*.ts",
|
||||
"./src/*/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user