feat: migrate over kshook sv6c stuff

This commit is contained in:
zk
2026-03-25 00:23:48 +00:00
parent 35c968738b
commit 9e9c1f3dfe
38 changed files with 898 additions and 128 deletions
@@ -0,0 +1,257 @@
---
name: actions-and-pg-migration
description: Patterns for writing actions (MakeAction/MakeAnonAction), using the Postgres DB (Kysely), and migrating Express routes from MongoDB to Postgres in the Tachi server. Use when adding a new mutation, migrating a Mongo-backed router to Postgres, writing action files, or writing tests for actions or routers.
---
# Actions & Postgres Migration — Tachi Server
## Actions
### Signatures
All action input/output schemas live in `src/lib/actions/actions.ts`. Add a new entry to `ActionSignatures` (for authenticated actions) or `AnonActionSignatures` (for unauthenticated ones):
```typescript
// src/lib/actions/actions.ts
MY_ACTION: {
input: z.object({ ... }),
output: z.object({ ... }),
},
```
### Action files
Place the implementation in `src/actions/my-action.ts`. Always use `.js` extensions on imports (the package uses ESM):
```typescript
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { ExpectedErr } from "bliss";
export const ACTION_MyAction = MakeAction(
"MY_ACTION",
async (taker, { fieldA, fieldB }) => {
// taker.acct.id — authenticated user's numeric ID
// taker.acct.username — their username
// taker.ip — request IP (for audit log)
if (somethingWrong) {
throw new ExpectedErr(400, "Human-readable reason.");
}
await DB.insertInto("priv_some_table").values({ ... }).execute();
return { result: "value" }; // must match output schema
},
);
```
`MakeAction` automatically writes an `action` row (`kind`, `result: "GOOD"|"BAD"`, `ip`, `user_id`) to the `action` audit table on every call. `MakeAnonAction` is the same but `taker` only has `ip` (no `acct`).
### ExpectedErr
`ExpectedErr(code, reason)` is an intentional control-flow error. Throw it for 400/403/404/409 etc. The global Express error handler in `server.ts` (`MAIN_ERR_HANDLER`) catches it and returns `{ success: false, description: reason }` with the right HTTP status. **Never wrap action calls in `try/catch`** — let it propagate.
### Calling actions from a router
```typescript
// taker construction — same pattern everywhere:
const user = req.session.tachi?.user;
if (!user) return res.status(401).json({ success: false, description: "..." });
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
// No try/catch — errors reach MAIN_ERR_HANDLER automatically
const result = await ACTION_MyAction(taker, { fieldA: body.fieldA });
return res.status(200).json({ success: true, description: "...", body: result });
```
---
## Postgres / Kysely
### DB import
```typescript
import DB from "#services/pg/db"; // routers / utils
import DB from "#services/pg/db.js"; // action files (ESM)
```
`DB` is a typed `Kysely<Database>` instance. Types come from the generated `tachi-db` workspace package (`src/generated/public/Priv*.ts`).
### Table naming
Postgres tables use `snake_case`. Private/sensitive tables are prefixed `priv_`. Permissions are sparse boolean columns named `pm_<permission_name>`.
| Concept | Mongo | Postgres |
|---|---|---|
| API clients | `api-clients` | `priv_api_client` |
| API tokens | `api-tokens` | `priv_api_token` |
| Users | `users` | `account` |
### Column lists & document mappers
Reusable `SELECT_*` arrays and `To*Document` mappers live in `src/lib/db-formats/`. Use them to avoid repetition and keep types consistent:
```typescript
import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client";
const rows = await DB.selectFrom("priv_api_client")
.select(SELECT_API_CLIENT)
.where("author", "=", userId)
.execute();
const docs = rows.map(ToAPIClientDocument); // → TachiAPIClientDocument[]
```
`SELECT_API_CLIENT` includes `client_secret`. For public-facing lookups, strip it after fetching:
```typescript
const { clientSecret: _secret, ...publicDoc } = doc;
```
### Common query patterns
```typescript
// Select one
await DB.selectFrom("priv_api_client")
.select(["client_id", "author"])
.where("client_id", "=", id)
.executeTakeFirst(); // returns undefined if not found
// Insert
await DB.insertInto("priv_api_client").values({ ... }).execute();
// Update (fetch after separately — don't use .returning() with prefixed column lists)
await DB.updateTable("priv_api_client").set({ name: "New" }).where("client_id", "=", id).execute();
const updated = await GetClientByID(id); // re-fetch via existing query helper
// Delete
await DB.deleteFrom("priv_api_token").where("from_oauth2_client", "=", id).execute();
// Count
const { count } = await DB.selectFrom("priv_api_client")
.select(DB.fn.countAll().as("count"))
.where("author", "=", userId)
.executeTakeFirstOrThrow();
```
Existing query helpers (e.g. `GetClientByID`) live in `src/utils/queries/`. Use them in routers; actions can query directly.
---
## Migrating a Mongo-backed router to Postgres
### Checklist
1. **Identify Mongo collections** → find the equivalent `priv_*` Postgres table.
2. **Reads** — replace `MONGODB_KILL["collection"].find/findOne()` with Kysely selects. Use the existing `SELECT_*` + `To*Document` helpers from `src/lib/db-formats/`.
3. **Mutations** — extract each write operation into a `MakeAction`-wrapped file in `src/actions/`. Add its signature to `ActionSignatures` in `actions.ts`.
4. **Middleware** — update any middleware that does Mongo lookups (e.g. `GetClientFromID`) to use a query helper or direct Kysely query.
5. **Remove Mongo import** — `MONGODB_KILL` should be gone from the file.
6. **Ownership checks** — move them inside the action (`taker.acct.id === row.author`) rather than in Express middleware, so the action is self-contained.
7. **Error handler** — throw `ExpectedErr` instead of returning early; no `try/catch` in routes needed.
### Permission columns
Mongo stored permissions as an array. Postgres uses individual nullable boolean columns (`pm_submit_score`, `pm_customise_profile`, etc.). Convert with:
```typescript
// array → columns
perms.includes("submit_score") ? true : null // null = permission not granted
// columns → array (see ToAPIClientDocument for full example)
if (row.pm_submit_score) result.push("submit_score");
```
---
## Testing
### Framework & setup
- **Vitest** + colocated `*.test.ts` files (not `__tests__/` folders)
- Each test worker gets its own DB, truncated between every test via `vitest.setup.ts`
- Run with `bun run test`
### Action tests (unit-style)
```typescript
import { seedUser } from "#test-utils/pg-fixtures";
import { seedApiClient } from "./test-utils/api-tokens";
describe("ACTION_MyAction", () => {
let userId: number;
let username: string;
beforeEach(async () => {
({ id: userId, username } = await seedUser({ username: "test_user" }));
});
it("throws 404 when resource does not exist", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(ACTION_MyAction(taker, { id: "missing" }))
.rejects.toMatchObject({ code: 404 });
});
it("writes a GOOD action row on success", async () => {
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
await ACTION_MyAction(taker, { ... });
const row = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "MY_ACTION")
.executeTakeFirstOrThrow();
expect(row).toMatchObject({ result: "GOOD", ip: "10.0.0.1", user_id: userId });
});
});
```
### Router integration tests
```typescript
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
import { seedUser } from "#test-utils/pg-fixtures";
afterAll(() => CloseServerConnection());
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[];
}
describe("POST /api/v1/some/route", () => {
let cookie: string[];
beforeEach(async () => {
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/some/route").send({ ... });
expect(res.status).toBe(401);
});
it("returns 200 on success", async () => {
const res = await mockApi.post("/api/v1/some/route")
.set("Cookie", cookie)
.send({ ... });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
```
### Key test fixtures
| Helper | Source | Purpose |
|---|---|---|
| `seedUser(opts?)` | `#test-utils/pg-fixtures` | Insert `account` row; `withCredential` + `withSettings` needed for login |
| `seedApiClient(opts)` | `src/actions/test-utils/api-tokens` | Insert `priv_api_client` row |
| `seedApiToken(opts)` | `src/actions/test-utils/api-tokens` | Insert `priv_api_token` row |
| `getApiToken(token)` | `src/actions/test-utils/api-tokens` | Select a token row by value |
+132
View File
@@ -0,0 +1,132 @@
---
name: db-formats
description: Guidance on using db-formats/ column lists (SELECT_*) and document mappers (To*Document) instead of selectAll() when writing Postgres-backed endpoints in the Tachi server. Use when writing a new endpoint that reads from a Postgres table, adding a new db-formats file, or when working with Kysely selects that need to return API-compatible (v1-mongodb-like) response shapes.
---
# db-formats: Column Lists & Document Mappers
## Why not `.selectAll()`
`.selectAll()` is banned in endpoint code because it:
- Returns raw snake_case Postgres column names directly to API consumers (breaks the v1 MongoDB-compatible contract)
- Over-fetches columns (e.g. `client_secret` on a public endpoint, internal credential columns)
- Gives no compile-time guarantee that the row shape matches the `tachi-common` document type
**Exceptions** where `.selectAll()` is fine:
- Internal/private lookups that never reach API responses (e.g. `priv_account_credential` for password verification)
- Assertions on audit rows in tests (`DB.selectFrom("action").selectAll()...`)
---
## The pattern
Every Postgres table that is returned through the API needs two things in `src/lib/db-formats/<table>.ts`:
1. **`SELECT_*`** — a `const` array of `"table.column"` strings listing exactly the columns needed
2. **`To*Document`** — a mapper function that converts a typed Kysely row to the matching `tachi-common` document type
```typescript
// src/lib/db-formats/my-table.ts
import { type Selection } from "kysely";
import { type MyDocument } from "tachi-common";
import { type Database } from "tachi-db";
export const SELECT_MY_TABLE = [
"my_table.user_id",
"my_table.some_column",
] as const;
export function ToMyDocument(
row: Selection<Database, "my_table", (typeof SELECT_MY_TABLE)[number]>,
): MyDocument {
return {
userID: row.user_id, // snake_case → camelCase
someColumn: row.some_column,
};
}
```
The `Selection<Database, "table", (typeof SELECT_*)[number]>` type ensures the mapper only receives columns that were actually selected — Kysely will error at compile time if the select array and mapper fall out of sync.
---
## Using it in an endpoint
```typescript
import { SELECT_MY_TABLE, ToMyDocument } from "#lib/db-formats/my-table";
// Many rows
const rows = await DB.selectFrom("my_table")
.select(SELECT_MY_TABLE)
.where("user_id", "=", userId)
.execute();
return res.status(200).json({
success: true,
description: "...",
body: rows.map(ToMyDocument),
});
// Single row
const row = await DB.selectFrom("my_table")
.select(SELECT_MY_TABLE)
.where("user_id", "=", userId)
.executeTakeFirst();
return res.status(200).json({
success: true,
description: "...",
body: row ? ToMyDocument(row) : null,
});
```
---
## v1-MongoDB-compatible mapping conventions
The `To*Document` function is responsible for all shape translation. Common patterns:
| Postgres column type | API document shape | Example |
|---|---|---|
| `snake_case` columns | `camelCase` fields | `user_id` → `userID`, `client_id` → `clientID` |
| `ISO 8601` timestamp string | Unix milliseconds (`number`) | `ISO8601ToUnixMilliseconds(row.joined)` → `joinDate` |
| `pm_*` nullable boolean columns | Array or permissions object | `if (row.pm_submit_score) perms.push("submit_score")` |
| `bd_*` boolean badge columns | `badges: UserBadges[]` array | `if (row.bd_alpha) badges.push("alpha")` |
| `sm_*` social media columns | `socialMedia: { ... }` object | `{ discord: row.sm_discord, ... }` |
| V3 combined game column | `{ game, playtype }` split | `V3ToGamePT(row.game)` |
| `auth_level` enum string | `authLevel: number` | `AuthLevelToInt(row.auth_level)` |
---
## Stripping sensitive columns on public endpoints
`SELECT_API_CLIENT` includes `client_secret`. If the endpoint is public-facing (not the client owner), strip it after mapping:
```typescript
const { clientSecret: _secret, ...publicDoc } = ToAPIClientDocument(row);
```
---
## Adding a new db-formats file: checklist
1. Create `src/lib/db-formats/<table>.ts`
2. Export `SELECT_<TABLE>` as a `const` array of `"table.column"` strings (use the qualified `table.column` form, not bare column names, to avoid ambiguity in joins)
3. Export `To<Entity>Document` typed with `Selection<Database, "table", (typeof SELECT_<TABLE>)[number]>`
4. Return the matching `tachi-common` document type (import from `"tachi-common"`)
5. Apply all naming/shape conventions from the table above
6. Import and use `SELECT_*` + `To*Document` in the router/util — never `.selectAll()`
---
## Existing db-formats files
| File | Table | Document type |
|---|---|---|
| `api-client.ts` | `priv_api_client` | `TachiAPIClientDocument` |
| `api-token.ts` | `priv_api_token` | `APITokenDocument` |
| `user.ts` | `account` | `UserDocument` |
| `user-settings.ts` | `account_settings` | `UserSettingsDocument` |
| `game-stats.ts` | `game_stats` | `UserGameStats` |
| `kshook-sv6c-settings.ts` | `svc_kshook_sv6c_settings` | `KsHookSettingsDocument` |
+21
View File
@@ -67,11 +67,15 @@ export function MakeActionGuts({
appName,
kind,
fn: actionBodyFn,
inputSchema,
outputSchema,
}: {
appName: string;
db: any;
fn: (taker: ActionTaker | AnonActionTaker, input: object) => Promise<object>;
inputSchema: ZodObject;
kind: string;
outputSchema: ZodObject;
}): unknown {
return async (taker: ActionTaker | AnonActionTaker, input: Record<string, unknown>) => {
const ts_start = new Date();
@@ -88,8 +92,25 @@ export function MakeActionGuts({
log.debug({ input: OmitPrivate(input) }, "Action started");
try {
const inputParsed = inputSchema.safeParse(input);
if (!inputParsed.success) {
throw new Error(
`Action ${kind} received invalid input: ${inputParsed.error.message}`,
);
}
retval = await actionBodyFn(taker, input);
const outputParsed = outputSchema.safeParse(retval);
if (!outputParsed.success) {
log.error(
{ errors: outputParsed.error.issues, output: OmitPrivate(retval ?? {}) },
`Action ${kind} returned invalid output`,
);
}
outputJSON = JSON.stringify(OmitPrivate(retval ?? {}));
log.debug(
{ input: OmitPrivate(input), output: OmitPrivate(retval ?? {}) },
+1 -1
View File
@@ -1,4 +1,4 @@
import eslint from "@eslint";
import eslint from "@eslint/js";
import configPrettier from "eslint-config-prettier";
import pluginImport from "eslint-plugin-import";
import pluginJsxA11y from "eslint-plugin-jsx-a11y";
@@ -193,7 +193,9 @@ describe("ACTION_ChangeEmail", () => {
});
beforeEach(() => {
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = { FROM: "noreply@example.com" };
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = {
FROM: "noreply@example.com",
};
});
it("inserts a verify-email token for the new address", async () => {
@@ -164,7 +164,12 @@ describe("ACTION_ChangePfp", () => {
.where("kind", "=", "CHANGE_PFP")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({ kind: "CHANGE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId });
expect(action).toMatchObject({
kind: "CHANGE_PFP",
result: "GOOD",
ip: "10.0.0.1",
user_id: userId,
});
});
it("writes a BAD action row on invalid mimetype", async () => {
@@ -285,7 +290,12 @@ describe("ACTION_DeletePfp", () => {
.where("kind", "=", "DELETE_PFP")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({ kind: "DELETE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId });
expect(action).toMatchObject({
kind: "DELETE_PFP",
result: "GOOD",
ip: "10.0.0.1",
user_id: userId,
});
});
});
@@ -348,7 +358,10 @@ describe("ACTION_ChangeBanner", () => {
it("persists the content hash to custom_banner_location", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
await ACTION_ChangeBanner(taker, {
"!fileBuffer": JPEG_BUFFER,
fileMimetype: "image/jpeg",
});
expect(await getBannerLocation(userId)).toBe(HashSHA256(JPEG_BUFFER));
});
@@ -357,7 +370,10 @@ describe("ACTION_ChangeBanner", () => {
const other = await seedUser({ username: "other_user" });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
await ACTION_ChangeBanner(taker, {
"!fileBuffer": JPEG_BUFFER,
fileMimetype: "image/jpeg",
});
expect(await getBannerLocation(other.id)).toBeNull();
});
@@ -377,7 +393,10 @@ describe("ACTION_ChangeBanner", () => {
it("calls CDNStoreOrOverwrite with the correct URL and buffer", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
await ACTION_ChangeBanner(taker, {
"!fileBuffer": JPEG_BUFFER,
fileMimetype: "image/jpeg",
});
expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce();
expect(CDNStoreOrOverwrite).toHaveBeenCalledWith(
@@ -391,14 +410,22 @@ describe("ACTION_ChangeBanner", () => {
it("writes a GOOD action row on success", async () => {
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
await ACTION_ChangeBanner(taker, {
"!fileBuffer": JPEG_BUFFER,
fileMimetype: "image/jpeg",
});
const action = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "CHANGE_BANNER")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({ kind: "CHANGE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId });
expect(action).toMatchObject({
kind: "CHANGE_BANNER",
result: "GOOD",
ip: "10.0.0.1",
user_id: userId,
});
});
it("writes a BAD action row on invalid mimetype", async () => {
@@ -419,7 +446,10 @@ describe("ACTION_ChangeBanner", () => {
it("does not store the file buffer content in the audit log input", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
await ACTION_ChangeBanner(taker, {
"!fileBuffer": JPEG_BUFFER,
fileMimetype: "image/jpeg",
});
const action = await DB.selectFrom("action")
.select("input")
@@ -519,6 +549,11 @@ describe("ACTION_DeleteBanner", () => {
.where("kind", "=", "DELETE_BANNER")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({ kind: "DELETE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId });
expect(action).toMatchObject({
kind: "DELETE_BANNER",
result: "GOOD",
ip: "10.0.0.1",
user_id: userId,
});
});
});
@@ -12,7 +12,7 @@ import { ACTION_ChangeUsername } from "./change-username";
*/
async function seedUsernameChange(
userId: number,
opts: { ageDays?: number; username?: string; previousUsername?: string } = {},
opts: { ageDays?: number; previousUsername?: string; username?: string } = {},
) {
const ageDays = opts.ageDays ?? 0;
const timestamp = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000).toISOString();
@@ -73,7 +73,10 @@ describe("ACTION_ChangeUsername", () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
ACTION_ChangeUsername(taker, {
newUsername: "new_username",
"!password": "wrongpassword",
}),
).rejects.toMatchObject({ code: 401 });
});
@@ -81,7 +84,10 @@ describe("ACTION_ChangeUsername", () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
ACTION_ChangeUsername(taker, {
newUsername: "new_username",
"!password": "wrongpassword",
}),
).rejects.toThrow();
const action = await DB.selectFrom("action")
@@ -96,7 +102,10 @@ describe("ACTION_ChangeUsername", () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
ACTION_ChangeUsername(taker, {
newUsername: "new_username",
"!password": "wrongpassword",
}),
).rejects.toThrow();
const row = await DB.selectFrom("account")
@@ -3,8 +3,8 @@ 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";
import { seedApiClient } from "./test-utils/api-tokens";
// ─── ACTION_CreateApiClient ───────────────────────────────────────────────────
@@ -23,7 +23,10 @@ function permissionsToColumns(perms: Array<APIPermissions>) {
export const ACTION_CreateApiClient = MakeAction(
"CREATE_API_CLIENT",
async (taker, { name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename, permissions }) => {
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));
@@ -2,8 +2,8 @@ import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { getApiToken, seedApiClient, seedApiToken } from "./test-utils/api-tokens";
import { ACTION_CreateApiToken } from "./create-api-token";
import { getApiToken, seedApiClient, seedApiToken } from "./test-utils/api-tokens";
// ─── ACTION_CreateApiToken ────────────────────────────────────────────────────
@@ -68,9 +68,7 @@ describe("ACTION_CreateApiToken", () => {
it("writes a BAD action row when the clientID does not exist", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_CreateApiToken(taker, { clientID: "CXNonExistent" }),
).rejects.toThrow();
await expect(ACTION_CreateApiToken(taker, { clientID: "CXNonExistent" })).rejects.toThrow();
const action = await DB.selectFrom("action")
.select("result")
@@ -23,10 +23,7 @@ export const ACTION_CreateApiToken = MakeAction(
"CREATE_API_TOKEN",
async (taker, { clientID, permissions, identifier }) => {
if (clientID !== undefined && permissions !== undefined) {
throw new ExpectedErr(
400,
"Cannot use clientID and permissions at the same time.",
);
throw new ExpectedErr(400, "Cannot use clientID and permissions at the same time.");
}
let tokenIdentifier: string;
@@ -3,12 +3,12 @@ import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_DeleteAllNotifications } from "./delete-all-notifications";
import {
countNotificationsForUser,
getNotification,
seedNotification,
} from "./test-utils/notifications";
import { ACTION_DeleteAllNotifications } from "./delete-all-notifications";
// ─── ACTION_DeleteAllNotifications ───────────────────────────────────────────
@@ -2,8 +2,8 @@ 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";
import { seedApiClient, seedApiToken } from "./test-utils/api-tokens";
// ─── ACTION_DeleteApiClient ───────────────────────────────────────────────────
@@ -31,9 +31,9 @@ describe("ACTION_DeleteApiClient", () => {
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 });
await expect(ACTION_DeleteApiClient(taker, { clientID: clientId })).rejects.toMatchObject({
code: 403,
});
});
// ── Happy path ────────────────────────────────────────────────────────────
@@ -70,7 +70,11 @@ describe("ACTION_DeleteApiClient", () => {
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" });
await seedApiToken({
token: "T_other_token",
userId: otherId,
fromClient: "CIOtherClient",
});
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
@@ -18,9 +18,7 @@ export const ACTION_DeleteApiClient = MakeAction(
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_token").where("from_oauth2_client", "=", clientID).execute();
await DB.deleteFrom("priv_api_client").where("client_id", "=", clientID).execute();
@@ -2,8 +2,8 @@ import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { getApiToken, seedApiToken } from "./test-utils/api-tokens";
import { ACTION_DeleteApiToken } from "./delete-api-token";
import { getApiToken, seedApiToken } from "./test-utils/api-tokens";
// ─── ACTION_DeleteApiToken ────────────────────────────────────────────────────
@@ -30,9 +30,9 @@ describe("ACTION_DeleteApiToken", () => {
await seedApiToken({ token: "OTHER_TOKEN", userId: other.id });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_DeleteApiToken(taker, { token: "OTHER_TOKEN" }),
).rejects.toMatchObject({ code: 404 });
await expect(ACTION_DeleteApiToken(taker, { token: "OTHER_TOKEN" })).rejects.toMatchObject({
code: 404,
});
});
it("writes a BAD action row when the token does not exist", async () => {
@@ -2,21 +2,18 @@ import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { ExpectedErr } from "bliss";
export const ACTION_DeleteApiToken = MakeAction(
"DELETE_API_TOKEN",
async (taker, { token }) => {
const existing = await DB.selectFrom("priv_api_token")
.select("token")
.where("token", "=", token)
.where("user_id", "=", taker.acct.id)
.executeTakeFirst();
export const ACTION_DeleteApiToken = MakeAction("DELETE_API_TOKEN", async (taker, { token }) => {
const existing = await DB.selectFrom("priv_api_token")
.select("token")
.where("token", "=", token)
.where("user_id", "=", taker.acct.id)
.executeTakeFirst();
if (!existing) {
throw new ExpectedErr(404, "This key does not exist.");
}
if (!existing) {
throw new ExpectedErr(404, "This key does not exist.");
}
await DB.deleteFrom("priv_api_token").where("token", "=", token).execute();
await DB.deleteFrom("priv_api_token").where("token", "=", token).execute();
return {};
},
);
return {};
});
@@ -3,8 +3,8 @@ import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { getNotification, seedNotification } from "./test-utils/notifications";
import { ACTION_MarkAllNotificationsRead } from "./mark-all-notifications-read";
import { getNotification, seedNotification } from "./test-utils/notifications";
// ─── ACTION_MarkAllNotificationsRead ─────────────────────────────────────────
@@ -2,8 +2,8 @@ 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";
import { seedApiClient } from "./test-utils/api-tokens";
// ─── ACTION_ResetApiClientSecret ──────────────────────────────────────────────
@@ -1,7 +1,7 @@
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 { GetClientByID } from "#utils/queries/api-clients.js";
import { ExpectedErr } from "bliss";
export const ACTION_ResetApiClientSecret = MakeAction(
@@ -14,7 +14,11 @@ describe("ACTION_UpdateApiClient", () => {
beforeEach(async () => {
({ id: userId, username } = await seedUser({ username: "test_user" }));
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" });
clientId = await seedApiClient({
clientId: "CITestClient",
authorId: userId,
name: "Old Name",
});
});
// ── Existence / ownership ─────────────────────────────────────────────────
@@ -41,9 +45,9 @@ describe("ACTION_UpdateApiClient", () => {
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 });
await expect(ACTION_UpdateApiClient(taker, { clientID: clientId })).rejects.toMatchObject({
code: 400,
});
});
it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => {
@@ -0,0 +1,105 @@
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_UpdateKshookSv6cSettings } from "./update-kshook-sv6c-settings";
async function getKshookSettings(userId: number) {
return DB.selectFrom("svc_kshook_sv6c_settings")
.selectAll()
.where("user_id", "=", userId)
.executeTakeFirst();
}
describe("ACTION_UpdateKshookSv6cSettings", () => {
let userId: number;
let username: string;
beforeEach(async () => {
({ id: userId, username } = await seedUser({ username: "test_user" }));
});
// ── Insert (no existing row) ───────────────────────────────────────────────
it("inserts a new row when none exists", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
const row = await getKshookSettings(userId);
expect(row).toBeDefined();
expect(row!.force_static_import).toBe(true);
});
it("returns the updated forceStaticImport value", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
const result = await ACTION_UpdateKshookSv6cSettings(taker, {
forceStaticImport: false,
});
expect(result).toEqual({ forceStaticImport: false });
});
// ── Update (existing row) ──────────────────────────────────────────────────
it("updates the existing row when one already exists", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false });
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
const row = await getKshookSettings(userId);
expect(row!.force_static_import).toBe(true);
});
it("does not create a second row on repeated calls", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false });
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
const count = await DB.selectFrom("svc_kshook_sv6c_settings")
.select(DB.fn.countAll().as("count"))
.where("user_id", "=", userId)
.executeTakeFirstOrThrow();
expect(Number(count.count)).toBe(1);
});
// ── Isolation ─────────────────────────────────────────────────────────────
it("does not affect other users' settings", async () => {
const other = await seedUser({ username: "other_user" });
const otherTaker = {
ip: "127.0.0.1",
acct: { id: other.id, username: other.username },
};
await ACTION_UpdateKshookSv6cSettings(otherTaker, { forceStaticImport: true });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false });
const otherRow = await getKshookSettings(other.id);
expect(otherRow!.force_static_import).toBe(true);
});
// ── Audit log ─────────────────────────────────────────────────────────────
it("writes a GOOD action row on success", async () => {
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
const action = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "UPDATE_KSHOOK_SV6C_SETTINGS")
.executeTakeFirstOrThrow();
expect(action).toMatchObject({
kind: "UPDATE_KSHOOK_SV6C_SETTINGS",
result: "GOOD",
ip: "10.0.0.1",
user_id: userId,
});
});
});
@@ -0,0 +1,16 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
export const ACTION_UpdateKshookSv6cSettings = MakeAction(
"UPDATE_KSHOOK_SV6C_SETTINGS",
async (taker, { forceStaticImport }) => {
await DB.insertInto("svc_kshook_sv6c_settings")
.values({ user_id: taker.acct.id, force_static_import: forceStaticImport })
.onConflict((oc) =>
oc.column("user_id").doUpdateSet({ force_static_import: forceStaticImport }),
)
.execute();
return { forceStaticImport };
},
);
@@ -1,8 +1,8 @@
import { MakeAnonAction } from "#lib/actions/actions";
import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth";
import { SendEmail } from "#lib/email/client";
import { EmailFormatVerifyEmail } from "#lib/email/formats";
import { Env, ServerConfig } from "#lib/setup/config";
import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user";
@@ -192,6 +192,10 @@ export const ActionSignatures = {
}),
output: z.object({}),
},
UPDATE_KSHOOK_SV6C_SETTINGS: {
input: z.object({ forceStaticImport: z.boolean() }),
output: z.object({ forceStaticImport: z.boolean() }),
},
} satisfies Record<string, ActionSignature>;
export const AnonActionSignatures = {
@@ -243,6 +247,8 @@ export function MakeAnonAction<A extends AnonActionName>(
db: DB,
appName: APP_NAME,
kind,
inputSchema: AnonActionSignatures[kind].input,
outputSchema: AnonActionSignatures[kind].output,
// @ts-expect-error we're being creative with the types here
fn,
}) as AnonActionFn<A>;
@@ -257,6 +263,8 @@ export function MakeAction<A extends ActionName>(kind: A, fn: ActionFn<A>): Acti
db: DB,
appName: APP_NAME,
kind,
inputSchema: ActionSignatures[kind].input,
outputSchema: ActionSignatures[kind].output,
// @ts-expect-error we're being creative with the types here
fn,
}) as ActionFn<A>;
@@ -0,0 +1,21 @@
import { type Selection } from "kysely";
import { type KsHookSettingsDocument } from "tachi-common";
import { type Database } from "tachi-db";
export const SELECT_KSHOOK_SV6C_SETTINGS = [
"svc_kshook_sv6c_settings.user_id",
"svc_kshook_sv6c_settings.force_static_import",
] as const;
export function ToKshookSv6cSettings(
row: Selection<
Database,
"svc_kshook_sv6c_settings",
(typeof SELECT_KSHOOK_SV6C_SETTINGS)[number]
>,
): KsHookSettingsDocument {
return {
userID: row.user_id,
forceStaticImport: row.force_static_import,
};
}
@@ -4,7 +4,6 @@ import type { FilterQuery } from "mongodb";
import { log } from "#lib/log/log";
import { DeorphanIfInQueue } from "#lib/orphan-queue/orphan-queue";
import MONGODB_KILL from "#services/mongo/db";
import { InitaliseFolderChartLookup } from "#utils/folder";
import { FormatBMSTables, WrapScriptPromise } from "#utils/misc";
import { type BMSTableEntry, LoadBMSTable } from "bms-table-loader";
import { BMS_TABLES, type BMSTableInfo, type ChartDocument, type Playtypes } from "tachi-common";
@@ -243,7 +242,7 @@ export async function SyncBMSTables() {
}
log.info(`Re-initialising folder-chart-lookup, since changes may have been made.`);
await InitaliseFolderChartLookup();
// await InitaliseFolderChartLookup();
log.info(`Done.`);
}
@@ -3,7 +3,7 @@
/* eslint-disable */
// @ts-nocheck
import { LookupRequest, LookupResponse } from "./cards_pb";
import { LookupRequest, LookupResponse } from "./cards_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
@@ -3,7 +3,7 @@
/* eslint-disable */
// @ts-nocheck
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb";
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
@@ -3,7 +3,7 @@
/* eslint-disable */
// @ts-nocheck
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb";
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
@@ -3,7 +3,7 @@
/* eslint-disable */
// @ts-nocheck
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb";
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
@@ -3,7 +3,7 @@
/* eslint-disable */
// @ts-nocheck
import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb";
import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
@@ -23,7 +23,6 @@ import { UpdateQuestSubscriptions } from "#lib/targets/quests";
import MONGODB_KILL, { monkDB } from "#services/mongo/db";
import { RecalcAllScores } from "#utils/calculations/recalc-scores";
import { UpdateGameSongIDCounter } from "#utils/db";
import { InitaliseFolderChartLookup } from "#utils/folder";
import { ArrayDiff, IsSupported, WrapScriptPromise } from "#utils/misc";
import fjsh from "fast-json-stable-hash";
@@ -187,7 +186,7 @@ const syncInstructions: Array<SyncInstructions> = [
const r = await GenericUpsert(charts, collection, "chartID", log, false);
if (r.thingsChanged) {
await InitaliseFolderChartLookup();
// await InitaliseFolderChartLookup();
await UpdateIsPrimaryStatus();
await UpdateGameSongIDCounter(collectionName.includes("bms") ? "bms" : "pms");
@@ -210,7 +209,7 @@ const syncInstructions: Array<SyncInstructions> = [
const r = await GenericUpsert(charts, collection, "chartID", log, true);
if (r.thingsChanged) {
await InitaliseFolderChartLookup();
// await InitaliseFolderChartLookup();
await UpdateIsPrimaryStatus();
await RecalcAllScores({
@@ -272,7 +271,7 @@ const syncInstructions: Array<SyncInstructions> = [
);
if (r.thingsChanged) {
await InitaliseFolderChartLookup();
// await InitaliseFolderChartLookup();
const allModifiedFolderIDs = r.changedFields as Array<string>;
@@ -1,10 +1,9 @@
import { seedApiClient, seedApiToken } from "#actions/test-utils/api-tokens";
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 ─────────────────────────────────────────────────────────────────
@@ -113,17 +112,14 @@ describe("POST /api/v1/clients/create", () => {
});
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: [],
});
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);
@@ -216,7 +212,11 @@ describe("GET /api/v1/clients/:clientID", () => {
beforeEach(async () => {
({ id: userId } = await seedUser({ username: "test_user" }));
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Test Client" });
clientId = await seedApiClient({
clientId: "CITestClient",
authorId: userId,
name: "Test Client",
});
});
it("returns 404 for a non-existent client", async () => {
@@ -257,13 +257,15 @@ describe("PATCH /api/v1/clients/:clientID", () => {
withSettings: true,
}));
cookie = await loginAs("test_user");
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" });
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" });
const res = await mockApi.patch(`/api/v1/clients/${clientId}`).send({ name: "New Name" });
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
@@ -427,9 +429,7 @@ describe("POST /api/v1/clients/:clientID/reset-secret", () => {
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);
await mockApi.post(`/api/v1/clients/${clientId}/reset-secret`).set("Cookie", cookie);
const token = await DB.selectFrom("priv_api_token")
.select("token")
@@ -465,9 +465,7 @@ describe("DELETE /api/v1/clients/:clientID", () => {
});
it("returns 404 for a non-existent client", async () => {
const res = await mockApi
.delete("/api/v1/clients/CINonExistent")
.set("Cookie", cookie);
const res = await mockApi.delete("/api/v1/clients/CINonExistent").set("Cookie", cookie);
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
@@ -482,18 +480,14 @@ describe("DELETE /api/v1/clients/:clientID", () => {
await seedApiClient({ clientId: "CIOther", authorId: otherId });
const otherCookie = await loginAs("other_user");
const res = await mockApi
.delete(`/api/v1/clients/${clientId}`)
.set("Cookie", otherCookie);
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);
const res = await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
@@ -1,8 +1,8 @@
import { CreateActivityRouteHandler } from "#lib/activity/activity";
import { PasswordCompare, ValidatePassword } from "#lib/auth/auth";
import { ONE_MONTH, ONE_WEEK, ONE_YEAR } from "#lib/constants/time";
import { log } from "#lib/log/log";
import prValidate from "#server/middleware/prudence-validate";
import { PasswordCompare, ValidatePassword } from "#lib/auth/auth";
import MONGODB_KILL from "#services/mongo/db";
import { IsString } from "#utils/misc";
import { GetTachiData, GetUGPT } from "#utils/req-tachi-data";
@@ -0,0 +1,177 @@
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";
afterAll(() => CloseServerConnection());
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[];
}
async function seedKshookSettings(userId: number, forceStaticImport: boolean) {
await DB.insertInto("svc_kshook_sv6c_settings")
.values({ user_id: userId, force_static_import: forceStaticImport })
.execute();
}
// ─── GET /api/v1/users/:userID/integrations/kshook-sv6c/settings ──────────────
describe("GET /api/v1/users/:userID/integrations/kshook-sv6c/settings", () => {
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/users/${userId}/integrations/kshook-sv6c/settings`);
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
});
it("returns null body when the user has no settings", async () => {
const res = await mockApi
.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body).toBeNull();
});
it("returns the user's settings when present", async () => {
await seedKshookSettings(userId, true);
const res = await mockApi
.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.body).toEqual({ userID: userId, forceStaticImport: true });
});
it("does not return another user's settings", async () => {
const other = await seedUser({ username: "other_user" });
await seedKshookSettings(other.id, true);
const res = await mockApi
.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.body).toBeNull();
});
});
// ─── PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings ────────────
describe("PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings", () => {
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
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.send({ forceStaticImport: true });
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
});
it("returns 403 when authenticated as a different user", async () => {
const other = await seedUser({
username: "other_user",
email: "other@example.com",
withCredential: true,
withSettings: true,
});
const otherCookie = await loginAs("other_user");
const res = await mockApi
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", otherCookie)
.send({ forceStaticImport: true });
expect(res.status).toBe(403);
// suppress unused-variable warning
void other;
});
it("creates a settings row and returns 200 on first call", async () => {
const res = await mockApi
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie)
.send({ forceStaticImport: true });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body).toEqual({ userID: userId, forceStaticImport: true });
});
it("updates an existing settings row", async () => {
await seedKshookSettings(userId, false);
const res = await mockApi
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie)
.send({ forceStaticImport: true });
expect(res.status).toBe(200);
expect(res.body.body.forceStaticImport).toBe(true);
});
it("persists the change to the database", async () => {
await mockApi
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie)
.send({ forceStaticImport: true });
const row = await DB.selectFrom("svc_kshook_sv6c_settings")
.selectAll()
.where("user_id", "=", userId)
.executeTakeFirstOrThrow();
expect(row.force_static_import).toBe(true);
});
it("returns 400 when forceStaticImport is missing", async () => {
const res = await mockApi
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie)
.send({});
expect(res.status).toBe(400);
});
it("returns 400 when forceStaticImport is not a boolean", async () => {
const res = await mockApi
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
.set("Cookie", cookie)
.send({ forceStaticImport: "yes" });
expect(res.status).toBe(400);
});
});
@@ -1,6 +1,11 @@
import { ACTION_UpdateKshookSv6cSettings } from "#actions/update-kshook-sv6c-settings.js";
import {
SELECT_KSHOOK_SV6C_SETTINGS,
ToKshookSv6cSettings,
} from "#lib/db-formats/kshook-sv6c-settings";
import prValidate from "#server/middleware/prudence-validate";
import { RequireKamaitachi } from "#server/middleware/type-require";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { GetTachiData } from "#utils/req-tachi-data";
import { Router } from "express";
@@ -19,14 +24,15 @@ router.use(RequireSelfRequestFromUser);
router.get("/settings", async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const settingsDoc = await MONGODB_KILL["kshook-sv6c-settings"].findOne({
userID: user.id,
});
const row = await DB.selectFrom("svc_kshook_sv6c_settings")
.select(SELECT_KSHOOK_SV6C_SETTINGS)
.where("user_id", "=", user.id)
.executeTakeFirst();
return res.status(200).json({
success: true,
description: `Retrieved KsHook (S6VC) settings.`,
body: settingsDoc ?? null,
body: row ? ToKshookSv6cSettings(row) : null,
});
});
@@ -35,33 +41,21 @@ router.get("/settings", async (req, res) => {
*
* @param forceStaticImport - Whether or whether not to statically import data.
*
* @name PUT /api/v1/users/:userID/integrations/kshook-sv6c/settings
* @name PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings
*/
router.patch("/settings", prValidate({ forceStaticImport: "boolean" }), async (req, res) => {
const body = req.safeBody as {
forceStaticImport: boolean;
};
const user = GetTachiData(req, "requestedUser");
await MONGODB_KILL["kshook-sv6c-settings"].update(
{ userID: user.id },
{
$set: {
forceStaticImport: body.forceStaticImport,
},
},
{
upsert: true,
},
);
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
const settings = await MONGODB_KILL["kshook-sv6c-settings"].findOne({ userID: user.id });
const result = await ACTION_UpdateKshookSv6cSettings(taker, {
forceStaticImport: req.body.forceStaticImport,
});
return res.status(200).json({
success: true,
description: `Successfully updated settings.`,
body: settings,
body: { userID: user.id, ...result },
});
});
+1 -1
View File
@@ -9,9 +9,9 @@ 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 { ExpectedErr } from "bliss";
import ExpressPromBundle from "express-prom-bundle";
import expressSession from "express-session";
import helmet from "helmet";
+1 -1
View File
@@ -201,7 +201,7 @@ export function GetUGPTPlaycount(userID: integer, game: GameGroup, playtype: Pla
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.executeTakeFirst()
.then((res) => res?.playcount ?? 0);
.then((res) => Number(res?.playcount ?? 0));
}
export async function GetAllRankings(stats: UserGameStats) {