mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-07 22:38:02 +03:00
feat: client stuff to postgres
This commit is contained in:
@@ -7,8 +7,7 @@ import useImport from "#components/util/import/useImport";
|
||||
import Loading from "#components/util/Loading";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { UserContext } from "#context/UserContext";
|
||||
import hashjs from "hash";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import { type APIImportTypes, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
@@ -127,13 +126,23 @@ function KAINeedsIntegrate({ kaiType, hash, clientID, redirectUri }: Omit<Props,
|
||||
});
|
||||
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [valid, setValid] = useState<boolean | null>(null);
|
||||
|
||||
const valid = useMemo(() => {
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
return null;
|
||||
setValid(null);
|
||||
return;
|
||||
}
|
||||
|
||||
return hashjs.sha256().update(url).digest("hex") === hash;
|
||||
const data = new TextEncoder().encode(url);
|
||||
|
||||
crypto.subtle.digest("SHA-256", data).then((hashBuffer) => {
|
||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
setValid(hashHex === hash);
|
||||
});
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
import { ACTION_CreateApiClient } from "./create-api-client";
|
||||
|
||||
// ─── ACTION_CreateApiClient ───────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_CreateApiClient", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── Input validation ──────────────────────────────────────────────────────
|
||||
|
||||
it("throws 400 when permissions list is empty", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("throws 400 for an invalid permission name", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["not_a_real_permission"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: "missing-the-placeholder",
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
// ── Client cap ────────────────────────────────────────────────────────────
|
||||
|
||||
it("throws 400 when user has reached OAUTH_CLIENT_CAP", async () => {
|
||||
for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) {
|
||||
await seedApiClient({ clientId: `CI_test_${i}`, authorId: userId });
|
||||
}
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_CreateApiClient(taker, {
|
||||
name: "One Too Many",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("does not apply the cap to admin users", async () => {
|
||||
const { id: adminId, username: adminUsername } = await seedUser({
|
||||
username: "admin_user",
|
||||
authLevel: "admin",
|
||||
});
|
||||
|
||||
for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) {
|
||||
await seedApiClient({ clientId: `CI_admin_${i}`, authorId: adminId });
|
||||
}
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: adminId, username: adminUsername } };
|
||||
|
||||
await expect(
|
||||
ACTION_CreateApiClient(taker, {
|
||||
name: "Admin Extra Client",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
}),
|
||||
).resolves.toMatchObject({ name: "Admin Extra Client" });
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
|
||||
it("inserts a row into priv_api_client", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select(["client_id", "name", "author"])
|
||||
.where("client_id", "=", result.clientID)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.name).toBe("My App");
|
||||
expect(row?.author).toBe(userId);
|
||||
});
|
||||
|
||||
it("returns a clientID matching CI prefix and a clientSecret matching CS prefix", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
expect(result.clientID).toMatch(/^CI[0-9a-f]{40}$/u);
|
||||
expect(result.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u);
|
||||
});
|
||||
|
||||
it("deduplicates permissions", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score", "submit_score", "customise_profile"],
|
||||
});
|
||||
|
||||
expect(result.requestedPermissions).toHaveLength(2);
|
||||
expect(result.requestedPermissions).toContain("submit_score");
|
||||
expect(result.requestedPermissions).toContain("customise_profile");
|
||||
});
|
||||
|
||||
it("stores redirect_uri and webhook_uri when provided", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: "https://example.com/callback",
|
||||
webhookUri: "https://example.com/webhook",
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select(["redirect_uri", "webhook_uri"])
|
||||
.where("client_id", "=", result.clientID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.redirect_uri).toBe("https://example.com/callback");
|
||||
expect(row.webhook_uri).toBe("https://example.com/webhook");
|
||||
});
|
||||
|
||||
it("stores apiKeyTemplate and apiKeyFilename when provided", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: "key=%%TACHI_KEY%%",
|
||||
apiKeyFilename: "tachi-key.txt",
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select(["api_key_template", "api_key_filename"])
|
||||
.where("client_id", "=", result.clientID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.api_key_template).toBe("key=%%TACHI_KEY%%");
|
||||
expect(row.api_key_filename).toBe("tachi-key.txt");
|
||||
});
|
||||
|
||||
it("sets pm_submit_score when that permission is requested", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select(["pm_submit_score", "pm_customise_profile"])
|
||||
.where("client_id", "=", result.clientID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.pm_submit_score).toBe(true);
|
||||
expect(row.pm_customise_profile).toBeNull();
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "CREATE_API_CLIENT")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "CREATE_API_CLIENT",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a BAD action row when permissions are invalid", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_CreateApiClient(taker, {
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CREATE_API_CLIENT")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { ServerConfig } from "#lib/setup/config.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { DedupeArr, Random20Hex } from "#utils/misc.js";
|
||||
import { IsUserAdmin } from "#utils/user.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common";
|
||||
|
||||
const VALID_PERMISSIONS = new Set(Object.keys(ALL_PERMISSIONS));
|
||||
|
||||
function permissionsToColumns(perms: Array<APIPermissions>) {
|
||||
return {
|
||||
pm_customise_profile: perms.includes("customise_profile") ? true : null,
|
||||
pm_customise_score: perms.includes("customise_score") ? true : null,
|
||||
pm_customise_session: perms.includes("customise_session") ? true : null,
|
||||
pm_delete_score: perms.includes("delete_score") ? true : null,
|
||||
pm_manage_rivals: perms.includes("manage_rivals") ? true : null,
|
||||
pm_manage_targets: perms.includes("manage_targets") ? true : null,
|
||||
pm_submit_score: perms.includes("submit_score") ? true : null,
|
||||
pm_manage_challenges: perms.includes("manage_challenges") ? true : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const ACTION_CreateApiClient = MakeAction(
|
||||
"CREATE_API_CLIENT",
|
||||
async (taker, { name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename, permissions }) => {
|
||||
const permissions_deduped = DedupeArr(permissions) as Array<APIPermissions>;
|
||||
|
||||
const invalid = permissions_deduped.filter((p) => !VALID_PERMISSIONS.has(p));
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new ExpectedErr(400, `Invalid permissions: ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
if (permissions_deduped.length === 0) {
|
||||
throw new ExpectedErr(400, "Must require at least one permission.");
|
||||
}
|
||||
|
||||
if (apiKeyTemplate !== null && !apiKeyTemplate.includes("%%TACHI_KEY%%")) {
|
||||
throw new ExpectedErr(400, "apiKeyTemplate must contain %%TACHI_KEY%%.");
|
||||
}
|
||||
|
||||
const isAdmin = await IsUserAdmin(taker.acct.id);
|
||||
|
||||
if (!isAdmin) {
|
||||
const existingCount = await DB.selectFrom("priv_api_client")
|
||||
.select(DB.fn.countAll().as("count"))
|
||||
.where("author", "=", taker.acct.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (Number(existingCount.count) >= ServerConfig.OAUTH_CLIENT_CAP) {
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
`You have created too many API clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const clientID = `CI${Random20Hex()}`;
|
||||
const clientSecret = `CS${Random20Hex()}`;
|
||||
|
||||
await DB.insertInto("priv_api_client")
|
||||
.values({
|
||||
client_id: clientID,
|
||||
client_secret: clientSecret,
|
||||
name,
|
||||
author: taker.acct.id,
|
||||
redirect_uri: redirectUri,
|
||||
webhook_uri: webhookUri,
|
||||
api_key_template: apiKeyTemplate,
|
||||
api_key_filename: apiKeyFilename,
|
||||
is_builtin: false,
|
||||
...permissionsToColumns(permissions_deduped),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return {
|
||||
clientID,
|
||||
clientSecret,
|
||||
name,
|
||||
author: taker.acct.id,
|
||||
requestedPermissions: permissions_deduped,
|
||||
redirectUri,
|
||||
webhookUri,
|
||||
apiKeyTemplate,
|
||||
apiKeyFilename,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient, seedApiToken } from "./test-utils/api-tokens";
|
||||
import { ACTION_DeleteApiClient } from "./delete-api-client";
|
||||
|
||||
// ─── ACTION_DeleteApiClient ───────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_DeleteApiClient", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
let clientId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId });
|
||||
});
|
||||
|
||||
// ── Existence / ownership ─────────────────────────────────────────────────
|
||||
|
||||
it("throws 404 when the client does not exist", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_DeleteApiClient(taker, { clientID: "CINonExistent" }),
|
||||
).rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("throws 403 when the taker is not the client owner", async () => {
|
||||
const { id: otherId, username: otherUsername } = await seedUser({ username: "other_user" });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherUsername } };
|
||||
|
||||
await expect(
|
||||
ACTION_DeleteApiClient(taker, { clientID: clientId }),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
|
||||
it("removes the client from priv_api_client", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteApiClient(taker, { clientID: clientId });
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("client_id")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes all api tokens associated with this client", async () => {
|
||||
await seedApiToken({ token: "T_token_1", userId, fromClient: clientId });
|
||||
await seedApiToken({ token: "T_token_2", userId, fromClient: clientId });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteApiClient(taker, { clientID: clientId });
|
||||
|
||||
const remaining = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("from_oauth2_client", "=", clientId)
|
||||
.execute();
|
||||
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not remove tokens belonging to other clients", async () => {
|
||||
const { id: otherId } = await seedUser({ username: "other_user" });
|
||||
await seedApiClient({ clientId: "CIOtherClient", authorId: otherId });
|
||||
await seedApiToken({ token: "T_other_token", userId: otherId, fromClient: "CIOtherClient" });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteApiClient(taker, { clientID: clientId });
|
||||
|
||||
const preserved = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("token", "=", "T_other_token")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(preserved).toBeDefined();
|
||||
});
|
||||
|
||||
it("succeeds and returns {} when the client has no tokens", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_DeleteApiClient(taker, { clientID: clientId });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteApiClient(taker, { clientID: clientId });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "DELETE_API_CLIENT")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "DELETE_API_CLIENT",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a BAD action row when client does not exist", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_DeleteApiClient(taker, { clientID: "CINonExistent" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "DELETE_API_CLIENT")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_DeleteApiClient = MakeAction(
|
||||
"DELETE_API_CLIENT",
|
||||
async (taker, { clientID }) => {
|
||||
const existing = await DB.selectFrom("priv_api_client")
|
||||
.select(["client_id", "author"])
|
||||
.where("client_id", "=", clientID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!existing) {
|
||||
throw new ExpectedErr(404, "This client does not exist.");
|
||||
}
|
||||
|
||||
if (existing.author !== taker.acct.id) {
|
||||
throw new ExpectedErr(403, "You are not authorized to perform this action.");
|
||||
}
|
||||
|
||||
await DB.deleteFrom("priv_api_token")
|
||||
.where("from_oauth2_client", "=", clientID)
|
||||
.execute();
|
||||
|
||||
await DB.deleteFrom("priv_api_client").where("client_id", "=", clientID).execute();
|
||||
|
||||
return {};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
import { ACTION_ResetApiClientSecret } from "./reset-api-client-secret";
|
||||
|
||||
// ─── ACTION_ResetApiClientSecret ──────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_ResetApiClientSecret", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
let clientId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId });
|
||||
});
|
||||
|
||||
// ── Existence / ownership ─────────────────────────────────────────────────
|
||||
|
||||
it("throws 404 when the client does not exist", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ResetApiClientSecret(taker, { clientID: "CINonExistent" }),
|
||||
).rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("throws 403 when the taker is not the client owner", async () => {
|
||||
const { id: otherId, username: otherUsername } = await seedUser({ username: "other_user" });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherUsername } };
|
||||
|
||||
await expect(
|
||||
ACTION_ResetApiClientSecret(taker, { clientID: clientId }),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
|
||||
it("generates a new secret different from the original", async () => {
|
||||
const originalRow = await DB.selectFrom("priv_api_client")
|
||||
.select("client_secret")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
await ACTION_ResetApiClientSecret(taker, { clientID: clientId });
|
||||
|
||||
const updatedRow = await DB.selectFrom("priv_api_client")
|
||||
.select("client_secret")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(updatedRow.client_secret).not.toBe(originalRow.client_secret);
|
||||
});
|
||||
|
||||
it("new secret matches CS prefix format", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
const result = await ACTION_ResetApiClientSecret(taker, { clientID: clientId });
|
||||
|
||||
expect(result.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u);
|
||||
});
|
||||
|
||||
it("persists the new secret to the database", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
const result = await ACTION_ResetApiClientSecret(taker, { clientID: clientId });
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("client_secret")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.client_secret).toBe(result.clientSecret);
|
||||
});
|
||||
|
||||
it("returns the full updated client document", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
const result = await ACTION_ResetApiClientSecret(taker, { clientID: clientId });
|
||||
|
||||
expect(result.clientID).toBe(clientId);
|
||||
expect(result.author).toBe(userId);
|
||||
expect(result.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u);
|
||||
});
|
||||
|
||||
it("does not invalidate existing tokens for the client", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await DB.insertInto("priv_api_token")
|
||||
.values({
|
||||
token: "T_existing_token",
|
||||
user_id: userId,
|
||||
identifier: "Test Token",
|
||||
from_oauth2_client: clientId,
|
||||
pm_submit_score: null,
|
||||
pm_customise_profile: null,
|
||||
pm_customise_score: null,
|
||||
pm_customise_session: null,
|
||||
pm_delete_score: null,
|
||||
pm_manage_rivals: null,
|
||||
pm_manage_targets: null,
|
||||
pm_manage_challenges: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await ACTION_ResetApiClientSecret(taker, { clientID: clientId });
|
||||
|
||||
const tokenStillExists = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("token", "=", "T_existing_token")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(tokenStillExists).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ResetApiClientSecret(taker, { clientID: clientId });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "RESET_API_CLIENT_SECRET")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "RESET_API_CLIENT_SECRET",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a BAD action row when client does not exist", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ResetApiClientSecret(taker, { clientID: "CINonExistent" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "RESET_API_CLIENT_SECRET")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { GetClientByID } from "#utils/queries/api-clients.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_ResetApiClientSecret = MakeAction(
|
||||
"RESET_API_CLIENT_SECRET",
|
||||
async (taker, { clientID }) => {
|
||||
const existing = await DB.selectFrom("priv_api_client")
|
||||
.select(["client_id", "author"])
|
||||
.where("client_id", "=", clientID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!existing) {
|
||||
throw new ExpectedErr(404, "This client does not exist.");
|
||||
}
|
||||
|
||||
if (existing.author !== taker.acct.id) {
|
||||
throw new ExpectedErr(403, "You are not authorized to perform this action.");
|
||||
}
|
||||
|
||||
const newSecret = `CS${Random20Hex()}`;
|
||||
|
||||
await DB.updateTable("priv_api_client")
|
||||
.set({ client_secret: newSecret })
|
||||
.where("client_id", "=", clientID)
|
||||
.execute();
|
||||
|
||||
const updated = await GetClientByID(clientID);
|
||||
|
||||
if (!updated) {
|
||||
throw new ExpectedErr(500, "Failed to retrieve updated client.");
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,214 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
import { ACTION_UpdateApiClient } from "./update-api-client";
|
||||
|
||||
// ─── ACTION_UpdateApiClient ───────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_UpdateApiClient", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
let clientId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" });
|
||||
});
|
||||
|
||||
// ── Existence / ownership ─────────────────────────────────────────────────
|
||||
|
||||
it("throws 404 when the client does not exist", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateApiClient(taker, { clientID: "CINonExistent", name: "New Name" }),
|
||||
).rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("throws 403 when the taker is not the client owner", async () => {
|
||||
const { id: otherId, username: otherUsername } = await seedUser({ username: "other_user" });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherUsername } };
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateApiClient(taker, { clientID: clientId, name: "Hijacked" }),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
});
|
||||
|
||||
// ── Input validation ──────────────────────────────────────────────────────
|
||||
|
||||
it("throws 400 when no fields are provided", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateApiClient(taker, { clientID: clientId }),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateApiClient(taker, {
|
||||
clientID: clientId,
|
||||
apiKeyTemplate: "missing-placeholder",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
|
||||
it("updates the name field", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_UpdateApiClient(taker, {
|
||||
clientID: clientId,
|
||||
name: "New Name",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("New Name");
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("name")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.name).toBe("New Name");
|
||||
});
|
||||
|
||||
it("updates redirectUri", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateApiClient(taker, {
|
||||
clientID: clientId,
|
||||
redirectUri: "https://example.com/callback",
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("redirect_uri")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.redirect_uri).toBe("https://example.com/callback");
|
||||
});
|
||||
|
||||
it("sets redirectUri to null", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateApiClient(taker, { clientID: clientId, redirectUri: null });
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("redirect_uri")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.redirect_uri).toBeNull();
|
||||
});
|
||||
|
||||
it("updates webhookUri", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateApiClient(taker, {
|
||||
clientID: clientId,
|
||||
webhookUri: "https://example.com/webhook",
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("webhook_uri")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.webhook_uri).toBe("https://example.com/webhook");
|
||||
});
|
||||
|
||||
it("updates apiKeyTemplate when it contains %%TACHI_KEY%%", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateApiClient(taker, {
|
||||
clientID: clientId,
|
||||
apiKeyTemplate: "key=%%TACHI_KEY%%",
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("api_key_template")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.api_key_template).toBe("key=%%TACHI_KEY%%");
|
||||
});
|
||||
|
||||
it("clears apiKeyTemplate when set to null", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateApiClient(taker, { clientID: clientId, apiKeyTemplate: null });
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("api_key_template")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.api_key_template).toBeNull();
|
||||
});
|
||||
|
||||
it("updates apiKeyFilename", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateApiClient(taker, { clientID: clientId, apiKeyFilename: "keys.txt" });
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("api_key_filename")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.api_key_filename).toBe("keys.txt");
|
||||
});
|
||||
|
||||
it("returns the updated client document", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_UpdateApiClient(taker, {
|
||||
clientID: clientId,
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(result.clientID).toBe(clientId);
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(result.author).toBe(userId);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateApiClient(taker, { clientID: clientId, name: "New Name" });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "UPDATE_API_CLIENT")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "UPDATE_API_CLIENT",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a BAD action row when client does not exist", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateApiClient(taker, { clientID: "CINonExistent", name: "New Name" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "UPDATE_API_CLIENT")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { GetClientByID } from "#utils/queries/api-clients.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_UpdateApiClient = MakeAction(
|
||||
"UPDATE_API_CLIENT",
|
||||
async (taker, { clientID, name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename }) => {
|
||||
const existing = await DB.selectFrom("priv_api_client")
|
||||
.select(["client_id", "author"])
|
||||
.where("client_id", "=", clientID)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!existing) {
|
||||
throw new ExpectedErr(404, "This client does not exist.");
|
||||
}
|
||||
|
||||
if (existing.author !== taker.acct.id) {
|
||||
throw new ExpectedErr(403, "You are not authorized to perform this action.");
|
||||
}
|
||||
|
||||
const updates: {
|
||||
api_key_filename?: string | null;
|
||||
api_key_template?: string | null;
|
||||
name?: string;
|
||||
redirect_uri?: string | null;
|
||||
webhook_uri?: string | null;
|
||||
} = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
if (redirectUri !== undefined) {
|
||||
updates.redirect_uri = redirectUri;
|
||||
}
|
||||
|
||||
if (webhookUri !== undefined) {
|
||||
updates.webhook_uri = webhookUri;
|
||||
}
|
||||
|
||||
if (apiKeyTemplate !== undefined) {
|
||||
if (apiKeyTemplate !== null && !apiKeyTemplate.includes("%%TACHI_KEY%%")) {
|
||||
throw new ExpectedErr(400, "apiKeyTemplate must contain %%TACHI_KEY%%.");
|
||||
}
|
||||
|
||||
updates.api_key_template = apiKeyTemplate;
|
||||
}
|
||||
|
||||
if (apiKeyFilename !== undefined) {
|
||||
updates.api_key_filename = apiKeyFilename;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new ExpectedErr(400, "No changes to make.");
|
||||
}
|
||||
|
||||
await DB.updateTable("priv_api_client")
|
||||
.set(updates)
|
||||
.where("client_id", "=", clientID)
|
||||
.execute();
|
||||
|
||||
const updated = await GetClientByID(clientID);
|
||||
|
||||
if (!updated) {
|
||||
throw new ExpectedErr(500, "Failed to retrieve updated client.");
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
@@ -128,6 +128,70 @@ export const ActionSignatures = {
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
CREATE_API_CLIENT: {
|
||||
input: z.object({
|
||||
name: z.string().min(3).max(80),
|
||||
redirectUri: z.url().nullable(),
|
||||
webhookUri: z.url().nullable(),
|
||||
apiKeyTemplate: z.string().nullable(),
|
||||
apiKeyFilename: z.string().nullable(),
|
||||
permissions: z.array(z.string()),
|
||||
}),
|
||||
output: z.object({
|
||||
clientID: z.string(),
|
||||
clientSecret: z.string(),
|
||||
name: z.string(),
|
||||
author: z.number().int(),
|
||||
requestedPermissions: z.array(z.string()),
|
||||
redirectUri: z.string().nullable(),
|
||||
webhookUri: z.string().nullable(),
|
||||
apiKeyTemplate: z.string().nullable(),
|
||||
apiKeyFilename: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
UPDATE_API_CLIENT: {
|
||||
input: z.object({
|
||||
clientID: z.string(),
|
||||
name: z.string().min(3).max(80).optional(),
|
||||
redirectUri: z.url().nullable().optional(),
|
||||
webhookUri: z.url().nullable().optional(),
|
||||
apiKeyTemplate: z.string().nullable().optional(),
|
||||
apiKeyFilename: z.string().min(3).max(80).nullable().optional(),
|
||||
}),
|
||||
output: z.object({
|
||||
clientID: z.string(),
|
||||
clientSecret: z.string(),
|
||||
name: z.string(),
|
||||
author: z.number().int(),
|
||||
requestedPermissions: z.array(z.string()),
|
||||
redirectUri: z.string().nullable(),
|
||||
webhookUri: z.string().nullable(),
|
||||
apiKeyTemplate: z.string().nullable(),
|
||||
apiKeyFilename: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
RESET_API_CLIENT_SECRET: {
|
||||
input: z.object({
|
||||
clientID: z.string(),
|
||||
}),
|
||||
output: z.object({
|
||||
clientID: z.string(),
|
||||
clientSecret: z.string(),
|
||||
name: z.string(),
|
||||
author: z.number().int(),
|
||||
requestedPermissions: z.array(z.string()),
|
||||
redirectUri: z.string().nullable(),
|
||||
webhookUri: z.string().nullable(),
|
||||
apiKeyTemplate: z.string().nullable(),
|
||||
apiKeyFilename: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
DELETE_API_CLIENT: {
|
||||
input: z.object({
|
||||
clientID: z.string(),
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
} satisfies Record<string, ActionSignature>;
|
||||
|
||||
export const AnonActionSignatures = {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
AggressiveRateLimitMiddleware,
|
||||
HyperAggressiveRateLimitMiddleware,
|
||||
} from "#server/middleware/rate-limiter";
|
||||
import { actionErrorToResponse, apiSuccess } from "#utils/response";
|
||||
import { apiSuccess } from "#utils/response";
|
||||
import {
|
||||
FormatUserDoc,
|
||||
GetSettingsForUser,
|
||||
@@ -187,23 +187,18 @@ router.post(
|
||||
username: string;
|
||||
};
|
||||
|
||||
let newUser: { userID: number };
|
||||
try {
|
||||
newUser = await ANON_ACTION_Register(
|
||||
{
|
||||
ip: req.ip,
|
||||
},
|
||||
{
|
||||
email: body.email,
|
||||
"!password": body["!password"],
|
||||
inviteCode: body.inviteCode ?? null,
|
||||
captcha: body.captcha,
|
||||
username: body.username,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
return actionErrorToResponse(res, err);
|
||||
}
|
||||
const newUser = await ANON_ACTION_Register(
|
||||
{
|
||||
ip: req.ip,
|
||||
},
|
||||
{
|
||||
email: body.email,
|
||||
"!password": body["!password"],
|
||||
inviteCode: body.inviteCode ?? null,
|
||||
captcha: body.captcha,
|
||||
username: body.username,
|
||||
},
|
||||
);
|
||||
|
||||
const user = await GetUserWithID(newUser.userID);
|
||||
|
||||
@@ -245,11 +240,7 @@ router.post(
|
||||
code: string;
|
||||
};
|
||||
|
||||
try {
|
||||
await ANON_ACTION_VerifyEmail({ ip: req.ip }, { code: body.code });
|
||||
} catch (err) {
|
||||
return actionErrorToResponse(res, err);
|
||||
}
|
||||
await ANON_ACTION_VerifyEmail({ ip: req.ip }, { code: body.code });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import type { RequestHandler } from "express";
|
||||
import type { TachiAPIClientDocument } from "tachi-common";
|
||||
|
||||
import { Env } from "#lib/setup/config";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetClientByID } from "#utils/queries/api-clients";
|
||||
import { AssignToReqTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
export const GetClientFromID: RequestHandler = async (req, res, next) => {
|
||||
const client = await MONGODB_KILL["api-clients"].findOne(
|
||||
{
|
||||
clientID: req.params.clientID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
clientSecret: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = await GetClientByID(req.params.clientID);
|
||||
|
||||
if (!client) {
|
||||
return res.status(404).json({
|
||||
@@ -24,45 +13,10 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { apiClientDoc: client });
|
||||
// Strip the client secret — this middleware is used for public lookups.
|
||||
const { clientSecret: _secret, ...publicClient } = client;
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => {
|
||||
let client: Omit<TachiAPIClientDocument, "clientSecret">;
|
||||
|
||||
// @hack
|
||||
// Sadly, expMiddlewareMock doesn't support mounting symbol props on
|
||||
// request. To hack around this for testing, we perform this hack.
|
||||
// There's an open issue for this here: https://github.com/i-like-robots/express-request-mock/issues/19
|
||||
/* istanbul ignore next */
|
||||
if (
|
||||
Env.NODE_ENV === "test" &&
|
||||
(req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument | undefined)
|
||||
) {
|
||||
// obviously a glaring hack and security flaw - this only applies
|
||||
// in testing.
|
||||
client = req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument;
|
||||
} else {
|
||||
client = GetTachiData(req, "apiClientDoc");
|
||||
}
|
||||
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated (for a session-level request, atleast).`,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.id !== client.author) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `You are not authorized to perform this action.`,
|
||||
});
|
||||
}
|
||||
AssignToReqTachiData(req, { apiClientDoc: publicClient });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient, seedApiToken } from "#actions/test-utils/api-tokens";
|
||||
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loginAs(username: string, password = "password123") {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username,
|
||||
"!password": password,
|
||||
captcha: "test",
|
||||
});
|
||||
|
||||
return res.headers["set-cookie"] as unknown as string[];
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/clients ──────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/v1/clients", () => {
|
||||
let cookie: string[];
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({
|
||||
username: "test_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.get("/api/v1/clients");
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 with empty array when user has no clients", async () => {
|
||||
const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only the authenticated user's clients", async () => {
|
||||
await seedApiClient({ clientId: "CIOwn", authorId: userId, name: "My Client" });
|
||||
|
||||
const { id: otherId } = await seedUser({ username: "other_user" });
|
||||
await seedApiClient({ clientId: "CIOther", authorId: otherId, name: "Other Client" });
|
||||
|
||||
const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body).toHaveLength(1);
|
||||
expect(res.body.body[0].clientID).toBe("CIOwn");
|
||||
});
|
||||
|
||||
it("includes clientSecret in the response (owner view)", async () => {
|
||||
await seedApiClient({ clientId: "CIOwn", authorId: userId });
|
||||
|
||||
const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body[0].clientSecret).toBeDefined();
|
||||
expect(res.body.body[0].clientSecret).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/v1/clients/create ─────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/v1/clients/create", () => {
|
||||
let cookie: string[];
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({
|
||||
username: "test_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.post("/api/v1/clients/create").send({
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({ permissions: ["submit_score"] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when permissions array is empty", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid permission names", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["not_valid"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when apiKeyTemplate is missing %%TACHI_KEY%%", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: "no-placeholder-here",
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a client and returns 200 with its data", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.name).toBe("My App");
|
||||
expect(res.body.body.clientID).toMatch(/^CI[0-9a-f]{40}$/u);
|
||||
expect(res.body.body.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u);
|
||||
expect(res.body.body.author).toBe(userId);
|
||||
});
|
||||
|
||||
it("persists the new client to the database", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
name: "Persistent App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: ["submit_score"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select(["client_id", "name"])
|
||||
.where("client_id", "=", res.body.body.clientID)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row?.name).toBe("Persistent App");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/clients/:clientID ───────────────────────────────────────────
|
||||
|
||||
describe("GET /api/v1/clients/:clientID", () => {
|
||||
let userId: number;
|
||||
let clientId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({ username: "test_user" }));
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Test Client" });
|
||||
});
|
||||
|
||||
it("returns 404 for a non-existent client", async () => {
|
||||
const res = await mockApi.get("/api/v1/clients/CINonExistent");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 with client data (no clientSecret)", async () => {
|
||||
const res = await mockApi.get(`/api/v1/clients/${clientId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.clientID).toBe(clientId);
|
||||
expect(res.body.body.name).toBe("Test Client");
|
||||
expect(res.body.body.clientSecret).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is accessible without authentication", async () => {
|
||||
const res = await mockApi.get(`/api/v1/clients/${clientId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PATCH /api/v1/clients/:clientID ─────────────────────────────────────────
|
||||
|
||||
describe("PATCH /api/v1/clients/:clientID", () => {
|
||||
let cookie: string[];
|
||||
let userId: number;
|
||||
let clientId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({
|
||||
username: "test_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" });
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/clients/${clientId}`)
|
||||
.send({ name: "New Name" });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 for a non-existent client", async () => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/clients/CINonExistent")
|
||||
.set("Cookie", cookie)
|
||||
.send({ name: "New Name" });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 403 when authenticated user does not own the client", async () => {
|
||||
const { id: otherId } = await seedUser({
|
||||
username: "other_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
await seedApiClient({ clientId: "CIOther", authorId: otherId, name: "Other" });
|
||||
const otherCookie = await loginAs("other_user");
|
||||
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", otherCookie)
|
||||
.send({ name: "Hijacked" });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when no fields are provided", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", cookie)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 and updated client when name is changed", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", cookie)
|
||||
.send({ name: "New Name" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.name).toBe("New Name");
|
||||
});
|
||||
|
||||
it("persists the name change to the database", async () => {
|
||||
await mockApi
|
||||
.patch(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", cookie)
|
||||
.send({ name: "Persisted Name" });
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("name")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.name).toBe("Persisted Name");
|
||||
});
|
||||
|
||||
it("returns 400 when apiKeyTemplate is missing %%TACHI_KEY%%", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", cookie)
|
||||
.send({ apiKeyTemplate: "no-placeholder" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/v1/clients/:clientID/reset-secret ─────────────────────────────
|
||||
|
||||
describe("POST /api/v1/clients/:clientID/reset-secret", () => {
|
||||
let cookie: string[];
|
||||
let userId: number;
|
||||
let clientId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({
|
||||
username: "test_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId });
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.post(`/api/v1/clients/${clientId}/reset-secret`);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 for a non-existent client", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/CINonExistent/reset-secret")
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 403 when authenticated user does not own the client", async () => {
|
||||
const { id: otherId } = await seedUser({
|
||||
username: "other_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
await seedApiClient({ clientId: "CIOther", authorId: otherId });
|
||||
const otherCookie = await loginAs("other_user");
|
||||
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/clients/${clientId}/reset-secret`)
|
||||
.set("Cookie", otherCookie);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 with the updated client including a new secret", async () => {
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/clients/${clientId}/reset-secret`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.clientSecret).toMatch(/^CS[0-9a-f]{40}$/u);
|
||||
});
|
||||
|
||||
it("persists the new secret to the database", async () => {
|
||||
const before = await DB.selectFrom("priv_api_client")
|
||||
.select("client_secret")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/clients/${clientId}/reset-secret`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const after = await DB.selectFrom("priv_api_client")
|
||||
.select("client_secret")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(after.client_secret).not.toBe(before.client_secret);
|
||||
expect(after.client_secret).toBe(res.body.body.clientSecret);
|
||||
});
|
||||
|
||||
it("does not remove existing tokens for the client", async () => {
|
||||
await seedApiToken({ token: "T_should_survive", userId, fromClient: clientId });
|
||||
|
||||
await mockApi
|
||||
.post(`/api/v1/clients/${clientId}/reset-secret`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
const token = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("token", "=", "T_should_survive")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(token).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /api/v1/clients/:clientID ────────────────────────────────────────
|
||||
|
||||
describe("DELETE /api/v1/clients/:clientID", () => {
|
||||
let cookie: string[];
|
||||
let userId: number;
|
||||
let clientId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({
|
||||
username: "test_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId });
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.delete(`/api/v1/clients/${clientId}`);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 for a non-existent client", async () => {
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/clients/CINonExistent")
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 403 when authenticated user does not own the client", async () => {
|
||||
const { id: otherId } = await seedUser({
|
||||
username: "other_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
await seedApiClient({ clientId: "CIOther", authorId: otherId });
|
||||
const otherCookie = await loginAs("other_user");
|
||||
|
||||
const res = await mockApi
|
||||
.delete(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", otherCookie);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 and removes the client", async () => {
|
||||
const res = await mockApi
|
||||
.delete(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = await DB.selectFrom("priv_api_client")
|
||||
.select("client_id")
|
||||
.where("client_id", "=", clientId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes all api tokens associated with the deleted client", async () => {
|
||||
await seedApiToken({ token: "T_linked_token", userId, fromClient: clientId });
|
||||
|
||||
await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", cookie);
|
||||
|
||||
const token = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("token", "=", "T_linked_token")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not remove tokens belonging to other clients", async () => {
|
||||
const { id: otherId } = await seedUser({ username: "other_user" });
|
||||
await seedApiClient({ clientId: "CIOther", authorId: otherId });
|
||||
await seedApiToken({ token: "T_other_token", userId: otherId, fromClient: "CIOther" });
|
||||
|
||||
await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", cookie);
|
||||
|
||||
const preserved = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("token", "=", "T_other_token")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(preserved).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,16 @@
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import { ACTION_CreateApiClient } from "#actions/create-api-client";
|
||||
import { ACTION_DeleteApiClient } from "#actions/delete-api-client";
|
||||
import { ACTION_ResetApiClientSecret } from "#actions/reset-api-client-secret";
|
||||
import { ACTION_UpdateApiClient } from "#actions/update-api-client";
|
||||
import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { DedupeArr, DeleteUndefinedProps, IsValidURL, Random20Hex } from "#utils/misc";
|
||||
import { optNull } from "#utils/prudence";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
type APIPermissions,
|
||||
type TachiAPIClientDocument,
|
||||
UserAuthLevels,
|
||||
} from "tachi-common";
|
||||
import { ALL_PERMISSIONS, type APIPermissions } from "tachi-common";
|
||||
|
||||
import { GetClientFromID, RequireOwnershipOfClient } from "./middleware";
|
||||
import { GetClientFromID } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
@@ -36,9 +31,12 @@ router.get("/", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const clients = await MONGODB_KILL["api-clients"].find({
|
||||
author: user.id,
|
||||
});
|
||||
const rows = await DB.selectFrom("priv_api_client")
|
||||
.select(SELECT_API_CLIENT)
|
||||
.where("author", "=", user.id)
|
||||
.execute();
|
||||
|
||||
const clients = rows.map(ToAPIClientDocument);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -66,26 +64,14 @@ router.post(
|
||||
name: p.isBoundedString(3, 80),
|
||||
redirectUri: "?string",
|
||||
webhookUri: "?string",
|
||||
apiKeyTemplate: (self) => {
|
||||
if (self === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
if (!self.includes("%%TACHI_KEY%%")) {
|
||||
return "Must contain %%TACHI_KEY%% as part of the template.";
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
apiKeyTemplate: "?string",
|
||||
apiKeyFilename: "?string",
|
||||
permissions: [p.isIn(Object.keys(ALL_PERMISSIONS))],
|
||||
}),
|
||||
async (req, res) => {
|
||||
if (!req.session.tachi?.user) {
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated.`,
|
||||
@@ -101,66 +87,16 @@ router.post(
|
||||
webhookUri: string | null;
|
||||
};
|
||||
|
||||
const existingClients = await MONGODB_KILL["api-clients"].find({
|
||||
author: req.session.tachi.user.id,
|
||||
});
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
// Note: Admins are excluded from the API client cap.
|
||||
if (
|
||||
req.session.tachi.user.authLevel !== UserAuthLevels.ADMIN &&
|
||||
existingClients.length >= ServerConfig.OAUTH_CLIENT_CAP
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `You have created too many API clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const permissions = DedupeArr<APIPermissions>(body.permissions);
|
||||
|
||||
if (permissions.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid permissions -- Need to require atleast one.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.redirectUri !== null && !IsValidURL(body.redirectUri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid Redirect URL.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.webhookUri !== null && !IsValidURL(body.webhookUri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid Webhook URL.`,
|
||||
});
|
||||
}
|
||||
|
||||
const clientID = `CI${Random20Hex()}`;
|
||||
const clientSecret = `CS${Random20Hex()}`;
|
||||
|
||||
const clientDoc: TachiAPIClientDocument = {
|
||||
clientID,
|
||||
clientSecret,
|
||||
requestedPermissions: permissions,
|
||||
const clientDoc = await ACTION_CreateApiClient(taker, {
|
||||
name: body.name,
|
||||
author: req.session.tachi.user.id,
|
||||
redirectUri: body.redirectUri,
|
||||
webhookUri: body.webhookUri ?? null,
|
||||
apiKeyFilename: body.apiKeyFilename ?? null,
|
||||
apiKeyTemplate: body.apiKeyTemplate ?? null,
|
||||
};
|
||||
|
||||
await MONGODB_KILL["api-clients"].insert(clientDoc);
|
||||
|
||||
log.info(
|
||||
`User ${FormatUserDoc(req.session.tachi.user)} created a new API Client ${
|
||||
body.name
|
||||
} (${clientID}).`,
|
||||
);
|
||||
webhookUri: body.webhookUri,
|
||||
apiKeyTemplate: body.apiKeyTemplate,
|
||||
apiKeyFilename: body.apiKeyFilename,
|
||||
permissions: body.permissions,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -192,94 +128,49 @@ router.get("/:clientID", GetClientFromID, (req, res) => {
|
||||
* @param name - Change the name of this client.
|
||||
* @param webhookUri - Change a bound webhookUri for this client.
|
||||
* @param redirectUri - Change a bound redirectUri for this client.
|
||||
* @param apiKeyFormat - Change the APIKeyFormat for this client.
|
||||
* @param apiKeyTemplate - Change the APIKeyTemplate for this client.
|
||||
* @param apiKeyFilename - Change the APIKeyFilename for this client.
|
||||
*
|
||||
* @name PATCH /api/v1/clients/:clientID
|
||||
*/
|
||||
router.patch(
|
||||
"/:clientID",
|
||||
GetClientFromID,
|
||||
RequireOwnershipOfClient,
|
||||
prValidate({
|
||||
name: p.optional(p.isBoundedString(3, 80)),
|
||||
apiKeyTemplate: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
if (!self.includes("%%TACHI_KEY%%")) {
|
||||
return "Must contain a %%TACHI_KEY%% placeholder.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
apiKeyFilename: optNull(p.isBoundedString(3, 80)),
|
||||
webhookUri: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
const res = IsValidURL(self);
|
||||
|
||||
if (!res) {
|
||||
return "Invalid URL.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
redirectUri: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
const res = IsValidURL(self);
|
||||
|
||||
if (!res) {
|
||||
return "Invalid URL.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
apiKeyTemplate: "?string",
|
||||
apiKeyFilename: p.optional(p.isBoundedString(3, 80)),
|
||||
webhookUri: "?string",
|
||||
redirectUri: "?string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated.`,
|
||||
});
|
||||
}
|
||||
|
||||
const body = req.safeBody as {
|
||||
apiKeyFilename?: string | null;
|
||||
apiKeyTemplate?: string | null;
|
||||
name?: string;
|
||||
permissions?: Array<APIPermissions>;
|
||||
redirectUri?: string | null;
|
||||
webhookUri?: string | null;
|
||||
};
|
||||
|
||||
const client = GetTachiData(req, "apiClientDoc");
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
DeleteUndefinedProps(req.safeBody);
|
||||
|
||||
if (Object.keys(req.safeBody).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `No changes to make.`,
|
||||
});
|
||||
}
|
||||
|
||||
const newClient = await MONGODB_KILL["api-clients"].findOneAndUpdate(
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
{
|
||||
$set: req.safeBody,
|
||||
},
|
||||
);
|
||||
|
||||
log.info(
|
||||
`API Client ${client.name} (${client.clientID}) has been renamed to ${body.name}.`,
|
||||
);
|
||||
const updatedClient = await ACTION_UpdateApiClient(taker, {
|
||||
clientID: req.params.clientID,
|
||||
...body,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Updated client.`,
|
||||
body: newClient,
|
||||
body: updatedClient,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -290,65 +181,51 @@ router.patch(
|
||||
*
|
||||
* @name POST /api/v1/clients/:clientID/reset-secret
|
||||
*/
|
||||
router.post(
|
||||
"/:clientID/reset-secret",
|
||||
GetClientFromID,
|
||||
RequireOwnershipOfClient,
|
||||
async (req, res) => {
|
||||
const client = GetTachiData(req, "apiClientDoc");
|
||||
const clientName = `${client.name} (${client.clientID})`;
|
||||
router.post("/:clientID/reset-secret", async (req, res) => {
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
log.info(`received request to reset client secret for ${clientName}`);
|
||||
|
||||
const newSecret = Random20Hex();
|
||||
|
||||
const newClient = await MONGODB_KILL["api-clients"].findOneAndUpdate(
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
{
|
||||
$set: { clientSecret: newSecret },
|
||||
},
|
||||
);
|
||||
|
||||
log.info(`Reset secret for ${clientName}.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reset secret.`,
|
||||
body: newClient,
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated.`,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
const updatedClient = await ACTION_ResetApiClientSecret(taker, {
|
||||
clientID: req.params.clientID,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reset secret.`,
|
||||
body: updatedClient,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete this client. Must be authorized at a session-request level.
|
||||
*
|
||||
* @name DELETE /api/v1/clients/:clientID
|
||||
*/
|
||||
router.delete("/:clientID", GetClientFromID, RequireOwnershipOfClient, async (req, res) => {
|
||||
const client = GetTachiData(req, "apiClientDoc");
|
||||
router.delete("/:clientID", async (req, res) => {
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
const clientName = `${client.name} (${client.clientID})`;
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated.`,
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`received request to destroy API Client ${client.name} (${client.clientID})`);
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
log.debug(`Removing API Client ${clientName}.`);
|
||||
await MONGODB_KILL["api-clients"].remove({
|
||||
clientID: client.clientID,
|
||||
});
|
||||
log.info(`Removed API Client ${clientName}.`);
|
||||
|
||||
log.debug(`Removing all associated api tokens.`);
|
||||
const result = await MONGODB_KILL["api-tokens"].remove({
|
||||
fromOAuth2Client: client.clientID,
|
||||
});
|
||||
|
||||
log.info(`Removed ${result.deletedCount} api tokens from ${clientName}.`);
|
||||
await ACTION_DeleteApiClient(taker, { clientID: req.params.clientID });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Deleted ${clientName}.`,
|
||||
description: `Deleted client ${req.params.clientID}.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import express, { type Express } from "express";
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env, ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { RedisClient } from "#services/redis/redis";
|
||||
import { IsNonEmptyString, IsRecord } from "#utils/misc";
|
||||
import ExpressPromBundle from "express-prom-bundle";
|
||||
@@ -205,6 +206,16 @@ const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) =>
|
||||
// else, this isn't a JSON parsing error
|
||||
}
|
||||
|
||||
// Action errors (ExpectedErr) carry an HTTP status code and a user-facing
|
||||
// reason. They are intentional control-flow throws and should not be logged
|
||||
// as fatal errors.
|
||||
if (ExpectedErr.is(err)) {
|
||||
return res.status(err.code).json({
|
||||
success: false,
|
||||
description: err.reason,
|
||||
});
|
||||
}
|
||||
|
||||
log.error({ url: req.originalUrl, body: req.body }, `MAIN_ERR_HANDLER hit by request.`);
|
||||
|
||||
const unknownErr = err as unknown;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { type TachiAPIFailResponse } from "./types";
|
||||
|
||||
export function apiSuccess<T = never>(
|
||||
@@ -25,19 +23,3 @@ export function apiFail(description: string): {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
import { type Response } from "express";
|
||||
|
||||
export function actionErrorToResponse(res: Response, err: unknown) {
|
||||
if (ExpectedErr.is(err)) {
|
||||
return res.status(err.code).json({
|
||||
success: false,
|
||||
description: err.reason,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: "An internal server error has occured.",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user