mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-13 01:38:29 +03:00
porting essential user things to actions, still
This commit is contained in:
+1
-1
@@ -56,7 +56,7 @@ test FILTER="*":
|
||||
|
||||
source .scripts/multi_evaluator.sh
|
||||
|
||||
evaluate "just test-typescript {{FILTER}}"
|
||||
evaluate "just test-typescript '{{FILTER}}'"
|
||||
|
||||
post_evaluate
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ZodObject } from "zod";
|
||||
|
||||
import { AppendLogCtx, log as baseLogger } from "./log.js";
|
||||
import { AppendLogCtx, log as baseLogger } from "./log";
|
||||
|
||||
export type ActionResult = "BAD" | "GOOD" | "THROW";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type RequestingUser } from "#slash-commands/types.js";
|
||||
import { type RequestingUser } from "#slash-commands/types";
|
||||
import { log } from "#utils/log";
|
||||
import { type CommandInteraction, MessageEmbed } from "discord.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { log } from "#utils/log.js";
|
||||
import { log } from "#utils/log";
|
||||
import { GetLimboChannel } from "#utils/misc";
|
||||
import { Client, type CommandInteraction, Intents, type SelectMenuInteraction } from "discord.js";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import db from "#services/pg/db";
|
||||
import { type RequestingUser } from "#slash-commands/types.js";
|
||||
import { log } from "#utils/log.js";
|
||||
import { type RequestingUser } from "#slash-commands/types";
|
||||
import { log } from "#utils/log";
|
||||
|
||||
export async function GetUserAndTokenForDiscordID(
|
||||
discordID: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APITokenDocument, UserDocument, WebhookEvents } from "tachi-common";
|
||||
|
||||
import { log } from "#utils/log.js";
|
||||
import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved.js";
|
||||
import { log } from "#utils/log";
|
||||
import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved";
|
||||
import express, { type Express } from "express";
|
||||
import path from "path";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Env } from "#config";
|
||||
import { PrependTachiUrl } from "#utils/fetch-tachi.js";
|
||||
import { PrependTachiUrl } from "#utils/fetch-tachi";
|
||||
import { log } from "#utils/log";
|
||||
import {
|
||||
GetGameGroupConfig,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Env } from "#config";
|
||||
import { PrependTachiUrl } from "#utils/fetch-tachi.js";
|
||||
import { PrependTachiUrl } from "#utils/fetch-tachi";
|
||||
import { log } from "#utils/log";
|
||||
import { GetGameGroupConfig, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common";
|
||||
|
||||
|
||||
@@ -7,7 +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.js";
|
||||
import hashjs from "hash";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import { type APIImportTypes, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
@@ -134,6 +134,10 @@ const v3GameMappings: Record<GPTString, V3Game> = {
|
||||
"ddr:DP": "ddr-dp",
|
||||
};
|
||||
|
||||
export function GamePTToV3(game: GameGroup, playtype: Playtype): V3Game {
|
||||
return GPTStringToV3(GetGPTString(game, playtype));
|
||||
}
|
||||
|
||||
export function GPTStringToV3(gptString: GPTString): V3Game {
|
||||
return v3GameMappings[gptString];
|
||||
}
|
||||
@@ -200,6 +204,12 @@ export function V3ToGameGroup(v3Game: V3Game): GameGroup {
|
||||
return mapping[v3Game];
|
||||
}
|
||||
|
||||
export function V3ToGamePT(v3Game: V3Game): { game: GameGroup; playtype: Playtype } {
|
||||
const gptString = V3ToGPTString(v3Game);
|
||||
const [game, playtype] = SplitGPT(gptString);
|
||||
return { game, playtype };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration for this Game + Playtype. The type here is expanded to
|
||||
* its most generic form, for easiest interaction.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import eslint from "@eslint/js";
|
||||
import eslint from "@eslint";
|
||||
import configPrettier from "eslint-config-prettier";
|
||||
import pluginImport from "eslint-plugin-import";
|
||||
import pluginJsxA11y from "eslint-plugin-jsx-a11y";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-console */
|
||||
import { ProcessEnv } from "./config.js";
|
||||
import { ProcessEnv } from "./config";
|
||||
import { App, createNodeMiddleware } from "@octokit/app";
|
||||
import express from "express";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type SongDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
import { log } from "../../log.js";
|
||||
import { log } from "../../log";
|
||||
import { CreateChartID, ReadCollection, WriteCollection } from "../../util";
|
||||
|
||||
const OMNIMIX_OPTION_NAMES = ["AOMN", "AOLD", "AKON"];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js";
|
||||
import { GetProfileBannerURL } from "#lib/cdn/url-format.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { HashSHA256 } from "#utils/crypto.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_ChangeBanner = MakeAction(
|
||||
"CHANGE_BANNER",
|
||||
async (taker, { "!fileBuffer": fileBuffer, fileMimetype }) => {
|
||||
const contentHash = HashSHA256(fileBuffer);
|
||||
|
||||
if (
|
||||
fileMimetype === "image/jpeg" ||
|
||||
fileMimetype === "image/png" ||
|
||||
fileMimetype === "image/gif"
|
||||
) {
|
||||
await CDNStoreOrOverwrite(GetProfileBannerURL(taker.acct.id, contentHash), fileBuffer);
|
||||
} else {
|
||||
// GIF is deliberately not mentioned here
|
||||
// as it's an easter egg
|
||||
throw new ExpectedErr(400, "Invalid file - only JPG and PNG files are supported.");
|
||||
}
|
||||
|
||||
await DB.updateTable("account")
|
||||
.set({ custom_banner_location: contentHash })
|
||||
.where("id", "=", taker.acct.id)
|
||||
.execute();
|
||||
|
||||
return { contentHash };
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,323 @@
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_ChangeEmail } from "./change-email";
|
||||
|
||||
// ─── ACTION_ChangeEmail ────────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_ChangeEmail", () => {
|
||||
const PASSWORD = "hunter2hunter2";
|
||||
const NEW_EMAIL = "new@example.com";
|
||||
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({
|
||||
username: "test_user",
|
||||
email: "old@example.com",
|
||||
password: PASSWORD,
|
||||
withCredential: true,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Wrong password ────────────────────────────────────────────────────────
|
||||
|
||||
it("throws 401 when the password is incorrect", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": "wrongpassword" }),
|
||||
).rejects.toMatchObject({ code: 401 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the password is wrong", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": "wrongpassword" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_EMAIL")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
it("does not change the stored email when the password is wrong", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": "wrongpassword" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const row = await DB.selectFrom("priv_account_credential")
|
||||
.select("email")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.email).toBe("old@example.com");
|
||||
});
|
||||
|
||||
// ── Email already in use ──────────────────────────────────────────────────
|
||||
|
||||
it("throws 409 when the new email is already in use by another user", async () => {
|
||||
await seedUser({
|
||||
username: "other_user",
|
||||
email: NEW_EMAIL,
|
||||
withCredential: true,
|
||||
});
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }),
|
||||
).rejects.toMatchObject({ code: 409 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the new email is already in use", async () => {
|
||||
await seedUser({
|
||||
username: "other_user",
|
||||
email: NEW_EMAIL,
|
||||
withCredential: true,
|
||||
});
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_EMAIL")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
it("does not change the stored email when the target email is already in use", async () => {
|
||||
await seedUser({
|
||||
username: "other_user",
|
||||
email: NEW_EMAIL,
|
||||
withCredential: true,
|
||||
});
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const row = await DB.selectFrom("priv_account_credential")
|
||||
.select("email")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.email).toBe("old@example.com");
|
||||
});
|
||||
|
||||
it("throws 409 when the user tries to change to their own current email", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeEmail(taker, { email: "old@example.com", "!password": PASSWORD }),
|
||||
).rejects.toMatchObject({ code: 409 });
|
||||
});
|
||||
|
||||
// ── Success path (no EMAIL_CONFIG) ────────────────────────────────────────
|
||||
|
||||
it("returns an empty object on success", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("updates the stored email to the new address", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const row = await DB.selectFrom("priv_account_credential")
|
||||
.select("email")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.email).toBe(NEW_EMAIL);
|
||||
});
|
||||
|
||||
it("does not insert a verify-email token when EMAIL_CONFIG is absent", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const rows = await DB.selectFrom("priv_verify_email_token")
|
||||
.select("token")
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not affect other users' credential rows", async () => {
|
||||
const other = await seedUser({
|
||||
username: "other_user",
|
||||
email: "other@example.com",
|
||||
withCredential: true,
|
||||
});
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const row = await DB.selectFrom("priv_account_credential")
|
||||
.select("email")
|
||||
.where("user_id", "=", other.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.email).toBe("other@example.com");
|
||||
});
|
||||
|
||||
// ── Success path (with EMAIL_CONFIG) ──────────────────────────────────────
|
||||
|
||||
describe("when EMAIL_CONFIG is set", () => {
|
||||
afterEach(() => {
|
||||
// Restore the config after each test in this group.
|
||||
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = undefined;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = { FROM: "noreply@example.com" };
|
||||
});
|
||||
|
||||
it("inserts a verify-email token for the new address", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const row = await DB.selectFrom("priv_verify_email_token")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.email).toBe(NEW_EMAIL);
|
||||
});
|
||||
|
||||
it("generates a non-empty hex token", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const row = await DB.selectFrom("priv_verify_email_token")
|
||||
.select("token")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.token).toMatch(/^[0-9a-f]{40}$/u);
|
||||
});
|
||||
|
||||
it("keeps exactly one token row for the user after the change", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const rows = await DB.selectFrom("priv_verify_email_token")
|
||||
.select("token")
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaces any pre-existing verify-email token", async () => {
|
||||
await seedVerifyEmailToken(userId, "old@example.com", "OLD_TOKEN_ABCDEF1234");
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const rows = await DB.selectFrom("priv_verify_email_token")
|
||||
.select(["token", "email"])
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.token).not.toBe("OLD_TOKEN_ABCDEF1234");
|
||||
expect(rows[0]?.email).toBe(NEW_EMAIL);
|
||||
});
|
||||
|
||||
it("does not touch verify-email tokens belonging to other users", async () => {
|
||||
const other = await seedUser({
|
||||
username: "other_user",
|
||||
email: "other@example.com",
|
||||
withCredential: true,
|
||||
});
|
||||
await seedVerifyEmailToken(other.id, "other@example.com", "OTHER_TOKEN_XYZ");
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const row = await DB.selectFrom("priv_verify_email_token")
|
||||
.select("token")
|
||||
.where("user_id", "=", other.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.token).toBe("OTHER_TOKEN_XYZ");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row to the audit log on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "CHANGE_EMAIL")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "CHANGE_EMAIL",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not store the plaintext password in the audit log input", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "CHANGE_EMAIL")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(JSON.stringify(action.input)).not.toContain(PASSWORD);
|
||||
});
|
||||
|
||||
it("records the new email address in the audit log input", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "CHANGE_EMAIL")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const input = action.input as Record<string, unknown>;
|
||||
|
||||
expect(input).toMatchObject({ email: NEW_EMAIL });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { PasswordCompare } from "#lib/auth/auth.js";
|
||||
import { SendEmail } from "#lib/email/client.js";
|
||||
import { EmailFormatVerifyEmail } from "#lib/email/formats.js";
|
||||
import { ServerConfig } from "#lib/setup/config.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
import { CheckIfEmailInUse } from "#utils/user.js";
|
||||
import { ExpectedErr, log } from "bliss";
|
||||
|
||||
export const ACTION_ChangeEmail = MakeAction(
|
||||
"CHANGE_EMAIL",
|
||||
async (taker, { email, "!password": password }) => {
|
||||
await DB.transaction()
|
||||
.setIsolationLevel("serializable")
|
||||
.execute(async (txn) => {
|
||||
const pw = await txn
|
||||
.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (!pw) {
|
||||
throw new ExpectedErr(500, "User has no password?");
|
||||
}
|
||||
|
||||
const isPasswordValid = await PasswordCompare(password, pw.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new ExpectedErr(401, "Invalid password");
|
||||
}
|
||||
|
||||
const existingEmail = await CheckIfEmailInUse(email);
|
||||
|
||||
if (existingEmail) {
|
||||
log.info(`User attempted to change to email that was already in use.`);
|
||||
throw new ExpectedErr(409, "This email is already in use.");
|
||||
}
|
||||
|
||||
await txn
|
||||
.updateTable("priv_account_credential")
|
||||
.set({
|
||||
email,
|
||||
})
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.execute();
|
||||
|
||||
if (ServerConfig.EMAIL_CONFIG) {
|
||||
const resetEmailCode = Random20Hex();
|
||||
|
||||
// clear out the previous email code!
|
||||
await DB.deleteFrom("priv_verify_email_token")
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("priv_verify_email_token")
|
||||
.values({
|
||||
email,
|
||||
token: resetEmailCode,
|
||||
user_id: taker.acct.id,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const { text, html } = EmailFormatVerifyEmail(
|
||||
taker.acct.username,
|
||||
resetEmailCode,
|
||||
);
|
||||
|
||||
void SendEmail(email, "Email Verification", html, text);
|
||||
}
|
||||
});
|
||||
|
||||
return {};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,210 @@
|
||||
import { PasswordCompare } from "#lib/auth/auth";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_ChangePassword } from "./change-password";
|
||||
|
||||
// ─── ACTION_ChangePassword ────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_ChangePassword", () => {
|
||||
const OLD_PASSWORD = "old_password_123";
|
||||
const NEW_PASSWORD = "new_password_456";
|
||||
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({
|
||||
username: "test_user",
|
||||
password: OLD_PASSWORD,
|
||||
withCredential: true,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Wrong old password ────────────────────────────────────────────────────
|
||||
|
||||
it("throws 401 when the old password is incorrect", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": "wrong_password_xxx",
|
||||
"!password": NEW_PASSWORD,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 401 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the old password is wrong", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": "wrong_password_xxx",
|
||||
"!password": NEW_PASSWORD,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_PASSWORD")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
it("does not modify the stored password hash when the old password is wrong", async () => {
|
||||
const before = await DB.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": "wrong_password_xxx",
|
||||
"!password": NEW_PASSWORD,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const after = await DB.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(after.password).toBe(before.password);
|
||||
});
|
||||
|
||||
// ── Success path ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns an empty object on success", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": OLD_PASSWORD,
|
||||
"!password": NEW_PASSWORD,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("replaces the stored password hash so the new password verifies", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": OLD_PASSWORD,
|
||||
"!password": NEW_PASSWORD,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await expect(PasswordCompare(NEW_PASSWORD, row.password)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates the old password after a successful change", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": OLD_PASSWORD,
|
||||
"!password": NEW_PASSWORD,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await expect(PasswordCompare(OLD_PASSWORD, row.password)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("stores a bcrypt hash, not the plaintext password", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": OLD_PASSWORD,
|
||||
"!password": NEW_PASSWORD,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.password).not.toBe(NEW_PASSWORD);
|
||||
expect(row.password).toMatch(/^\$2[ab]\$/u);
|
||||
});
|
||||
|
||||
it("does not affect other users' credential rows", async () => {
|
||||
const other = await seedUser({
|
||||
username: "other_user",
|
||||
email: "other@example.com",
|
||||
password: "other_users_password",
|
||||
withCredential: true,
|
||||
});
|
||||
|
||||
const otherBefore = await DB.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", other.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": OLD_PASSWORD,
|
||||
"!password": NEW_PASSWORD,
|
||||
});
|
||||
|
||||
const otherAfter = await DB.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", other.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(otherAfter.password).toBe(otherBefore.password);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row to the audit log on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": OLD_PASSWORD,
|
||||
"!password": NEW_PASSWORD,
|
||||
});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "CHANGE_PASSWORD")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "CHANGE_PASSWORD",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not store either password in the audit log input", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePassword(taker, {
|
||||
"!oldPassword": OLD_PASSWORD,
|
||||
"!password": NEW_PASSWORD,
|
||||
});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "CHANGE_PASSWORD")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const input = JSON.stringify(action.input);
|
||||
|
||||
expect(input).not.toContain(OLD_PASSWORD);
|
||||
expect(input).not.toContain(NEW_PASSWORD);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { HashPassword, PasswordCompare } from "#lib/auth/auth.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_ChangePassword = MakeAction(
|
||||
"CHANGE_PASSWORD",
|
||||
async (taker, { "!oldPassword": oldPassword, "!password": password }) => {
|
||||
await DB.transaction()
|
||||
.setIsolationLevel("serializable")
|
||||
.execute(async (txn) => {
|
||||
const pw = await txn
|
||||
.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (!pw) {
|
||||
throw new ExpectedErr(500, "User has no password?");
|
||||
}
|
||||
|
||||
const isPasswordValid = await PasswordCompare(oldPassword, pw.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new ExpectedErr(401, "Invalid password");
|
||||
}
|
||||
|
||||
const newPasswordHash = await HashPassword(password);
|
||||
|
||||
await txn
|
||||
.updateTable("priv_account_credential")
|
||||
.set({
|
||||
password: newPasswordHash,
|
||||
})
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.execute();
|
||||
});
|
||||
|
||||
return {};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,524 @@
|
||||
import { CDNDelete, CDNStoreOrOverwrite } from "#lib/cdn/cdn";
|
||||
import { GetProfileBannerURL, GetProfilePictureURL } from "#lib/cdn/url-format";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { HashSHA256 } from "#utils/crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ACTION_ChangeBanner } from "./change-banner";
|
||||
import { ACTION_ChangePfp } from "./change-pfp";
|
||||
import { ACTION_DeleteBanner } from "./delete-banner";
|
||||
import { ACTION_DeletePfp } from "./delete-pfp";
|
||||
|
||||
vi.mock("#lib/cdn/cdn.js", () => ({
|
||||
CDNStoreOrOverwrite: vi.fn().mockResolvedValue(undefined),
|
||||
CDNDelete: vi.fn().mockResolvedValue(undefined),
|
||||
CDNRedirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const JPEG_BUFFER = Buffer.from("fake-jpeg-data");
|
||||
const PNG_BUFFER = Buffer.from("fake-png-data");
|
||||
const GIF_BUFFER = Buffer.from("fake-gif-data");
|
||||
const BAD_BUFFER = Buffer.from("fake-webp-data");
|
||||
|
||||
async function getPfpLocation(userId: number) {
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("custom_pfp_location")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return row.custom_pfp_location;
|
||||
}
|
||||
|
||||
async function getBannerLocation(userId: number) {
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("custom_banner_location")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return row.custom_banner_location;
|
||||
}
|
||||
|
||||
async function seedUserWithPfp(userId: number, hash: string) {
|
||||
await DB.updateTable("account")
|
||||
.set({ custom_pfp_location: hash })
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function seedUserWithBanner(userId: number, hash: string) {
|
||||
await DB.updateTable("account")
|
||||
.set({ custom_banner_location: hash })
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// ─── ACTION_ChangePfp ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_ChangePfp", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── Mimetype validation ───────────────────────────────────────────────────
|
||||
|
||||
it("returns { contentHash } for a JPEG file", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangePfp(taker, {
|
||||
"!fileBuffer": JPEG_BUFFER,
|
||||
fileMimetype: "image/jpeg",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ contentHash: HashSHA256(JPEG_BUFFER) });
|
||||
});
|
||||
|
||||
it("returns { contentHash } for a PNG file", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangePfp(taker, {
|
||||
"!fileBuffer": PNG_BUFFER,
|
||||
fileMimetype: "image/png",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ contentHash: HashSHA256(PNG_BUFFER) });
|
||||
});
|
||||
|
||||
it("returns { contentHash } for a GIF file", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangePfp(taker, {
|
||||
"!fileBuffer": GIF_BUFFER,
|
||||
fileMimetype: "image/gif",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ contentHash: HashSHA256(GIF_BUFFER) });
|
||||
});
|
||||
|
||||
it("throws 400 for an unsupported mimetype", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
// ── Database updates ──────────────────────────────────────────────────────
|
||||
|
||||
it("persists the content hash to custom_pfp_location", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
|
||||
|
||||
expect(await getPfpLocation(userId)).toBe(HashSHA256(JPEG_BUFFER));
|
||||
});
|
||||
|
||||
it("does not update other users' custom_pfp_location", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
|
||||
|
||||
expect(await getPfpLocation(other.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not update custom_pfp_location on a bad mimetype", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(await getPfpLocation(userId)).toBeNull();
|
||||
});
|
||||
|
||||
// ── CDN calls ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("calls CDNStoreOrOverwrite with the correct URL and buffer", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
|
||||
|
||||
expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce();
|
||||
expect(CDNStoreOrOverwrite).toHaveBeenCalledWith(
|
||||
GetProfilePictureURL(userId, HashSHA256(JPEG_BUFFER)),
|
||||
JPEG_BUFFER,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "CHANGE_PFP")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
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 () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_PFP")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
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_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "CHANGE_PFP")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(JSON.stringify(action.input)).not.toContain(JPEG_BUFFER.toString("base64"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ACTION_DeletePfp ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_DeletePfp", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── 404 guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("throws 404 when the user has no custom pfp", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(ACTION_DeletePfp(taker, {})).rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the user has no custom pfp", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(ACTION_DeletePfp(taker, {})).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "DELETE_PFP")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
// ── Success path ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns {} on success", async () => {
|
||||
await seedUserWithPfp(userId, "existinghash");
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_DeletePfp(taker, {});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("clears custom_pfp_location to null in the DB", async () => {
|
||||
await seedUserWithPfp(userId, "existinghash");
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeletePfp(taker, {});
|
||||
|
||||
expect(await getPfpLocation(userId)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not touch other users' custom_pfp_location", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
await seedUserWithPfp(other.id, "otherhash");
|
||||
await seedUserWithPfp(userId, "myhash");
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeletePfp(taker, {});
|
||||
|
||||
expect(await getPfpLocation(other.id)).toBe("otherhash");
|
||||
});
|
||||
|
||||
// ── CDN calls ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("calls CDNDelete once with the correct URL", async () => {
|
||||
await seedUserWithPfp(userId, "existinghash");
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeletePfp(taker, {});
|
||||
|
||||
expect(CDNDelete).toHaveBeenCalledOnce();
|
||||
expect(CDNDelete).toHaveBeenCalledWith(GetProfilePictureURL(userId, "existinghash"));
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
await seedUserWithPfp(userId, "existinghash");
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeletePfp(taker, {});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "DELETE_PFP")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({ kind: "DELETE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ACTION_ChangeBanner ──────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_ChangeBanner", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── Mimetype validation ───────────────────────────────────────────────────
|
||||
|
||||
it("returns { contentHash } for a JPEG file", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangeBanner(taker, {
|
||||
"!fileBuffer": JPEG_BUFFER,
|
||||
fileMimetype: "image/jpeg",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ contentHash: HashSHA256(JPEG_BUFFER) });
|
||||
});
|
||||
|
||||
it("returns { contentHash } for a PNG file", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangeBanner(taker, {
|
||||
"!fileBuffer": PNG_BUFFER,
|
||||
fileMimetype: "image/png",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ contentHash: HashSHA256(PNG_BUFFER) });
|
||||
});
|
||||
|
||||
it("returns { contentHash } for a GIF file", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangeBanner(taker, {
|
||||
"!fileBuffer": GIF_BUFFER,
|
||||
fileMimetype: "image/gif",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ contentHash: HashSHA256(GIF_BUFFER) });
|
||||
});
|
||||
|
||||
it("throws 400 for an unsupported mimetype", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
// ── Database updates ──────────────────────────────────────────────────────
|
||||
|
||||
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" });
|
||||
|
||||
expect(await getBannerLocation(userId)).toBe(HashSHA256(JPEG_BUFFER));
|
||||
});
|
||||
|
||||
it("does not update other users' custom_banner_location", async () => {
|
||||
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" });
|
||||
|
||||
expect(await getBannerLocation(other.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not update custom_banner_location on a bad mimetype", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(await getBannerLocation(userId)).toBeNull();
|
||||
});
|
||||
|
||||
// ── CDN calls ─────────────────────────────────────────────────────────────
|
||||
|
||||
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" });
|
||||
|
||||
expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce();
|
||||
expect(CDNStoreOrOverwrite).toHaveBeenCalledWith(
|
||||
GetProfileBannerURL(userId, HashSHA256(JPEG_BUFFER)),
|
||||
JPEG_BUFFER,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
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" });
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row on invalid mimetype", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_BANNER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "CHANGE_BANNER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(JSON.stringify(action.input)).not.toContain(JPEG_BUFFER.toString("base64"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ACTION_DeleteBanner ──────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_DeleteBanner", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── 404 guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("throws 404 when the user has no custom banner", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(ACTION_DeleteBanner(taker, {})).rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the user has no custom banner", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(ACTION_DeleteBanner(taker, {})).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "DELETE_BANNER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
// ── Success path ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns {} on success", async () => {
|
||||
await seedUserWithBanner(userId, "existinghash");
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_DeleteBanner(taker, {});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("clears custom_banner_location to null in the DB", async () => {
|
||||
await seedUserWithBanner(userId, "existinghash");
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteBanner(taker, {});
|
||||
|
||||
expect(await getBannerLocation(userId)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not touch other users' custom_banner_location", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
await seedUserWithBanner(other.id, "otherhash");
|
||||
await seedUserWithBanner(userId, "myhash");
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteBanner(taker, {});
|
||||
|
||||
expect(await getBannerLocation(other.id)).toBe("otherhash");
|
||||
});
|
||||
|
||||
// ── CDN calls ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("calls CDNDelete once with the correct URL", async () => {
|
||||
await seedUserWithBanner(userId, "existinghash");
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteBanner(taker, {});
|
||||
|
||||
expect(CDNDelete).toHaveBeenCalledOnce();
|
||||
expect(CDNDelete).toHaveBeenCalledWith(GetProfileBannerURL(userId, "existinghash"));
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
await seedUserWithBanner(userId, "existinghash");
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteBanner(taker, {});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "DELETE_BANNER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({ kind: "DELETE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js";
|
||||
import { GetProfilePictureURL } from "#lib/cdn/url-format.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { HashSHA256 } from "#utils/crypto.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_ChangePfp = MakeAction(
|
||||
"CHANGE_PFP",
|
||||
async (taker, { "!fileBuffer": fileBuffer, fileMimetype }) => {
|
||||
const contentHash = HashSHA256(fileBuffer);
|
||||
|
||||
if (
|
||||
fileMimetype === "image/jpeg" ||
|
||||
fileMimetype === "image/png" ||
|
||||
fileMimetype === "image/gif"
|
||||
) {
|
||||
await CDNStoreOrOverwrite(GetProfilePictureURL(taker.acct.id, contentHash), fileBuffer);
|
||||
} else {
|
||||
// GIF is deliberately not mentioned here
|
||||
// as it's an easter egg
|
||||
throw new ExpectedErr(400, "Invalid file - only JPG and PNG files are supported.");
|
||||
}
|
||||
|
||||
await DB.updateTable("account")
|
||||
.set({ custom_pfp_location: contentHash })
|
||||
.where("id", "=", taker.acct.id)
|
||||
.execute();
|
||||
|
||||
return { contentHash };
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,311 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_ChangeUsername } from "./change-username";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert an `account_username_change` row with the given age in days, so tests
|
||||
* can exercise the 6-month cooldown without real time passing.
|
||||
*/
|
||||
async function seedUsernameChange(
|
||||
userId: number,
|
||||
opts: { ageDays?: number; username?: string; previousUsername?: string } = {},
|
||||
) {
|
||||
const ageDays = opts.ageDays ?? 0;
|
||||
const timestamp = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
await DB.insertInto("account_username_change")
|
||||
.values({
|
||||
user_id: userId,
|
||||
username: opts.username ?? "current_name",
|
||||
previous_username: opts.previousUsername ?? "old_name",
|
||||
timestamp,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// ─── ACTION_ChangeUsername ─────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_ChangeUsername", () => {
|
||||
const PASSWORD = "hunter2hunter2";
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({
|
||||
username: "original_user",
|
||||
password: PASSWORD,
|
||||
withCredential: true,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Same-username guard ────────────────────────────────────────────────────
|
||||
|
||||
it("throws 400 when newUsername is identical to the current username", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: username, "!password": PASSWORD }),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when newUsername matches the current username", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: username, "!password": PASSWORD }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_USERNAME")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
// ── Password validation ────────────────────────────────────────────────────
|
||||
|
||||
it("throws 401 when the password is incorrect", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
|
||||
).rejects.toMatchObject({ code: 401 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the password is wrong", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_USERNAME")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
it("does not modify the account row when the password is wrong", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("username")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.username).toBe(username);
|
||||
});
|
||||
|
||||
// ── Cooldown guard ────────────────────────────────────────────────────────
|
||||
|
||||
it("throws 400 when the user changed their username fewer than 6 months ago", async () => {
|
||||
await seedUsernameChange(userId, { ageDays: 10 });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": PASSWORD }),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the cooldown has not elapsed", async () => {
|
||||
await seedUsernameChange(userId, { ageDays: 10 });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": PASSWORD }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "CHANGE_USERNAME")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
it("does not modify the account row when within the cooldown period", async () => {
|
||||
await seedUsernameChange(userId, { ageDays: 10 });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": PASSWORD }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("username")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.username).toBe(username);
|
||||
});
|
||||
|
||||
it("allows a username change after the 6-month cooldown has elapsed", async () => {
|
||||
await seedUsernameChange(userId, { ageDays: 181 });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ prevUsername: username, newUsername: "new_username" });
|
||||
});
|
||||
|
||||
// ── Success path ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns the previous and new username on success", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ prevUsername: username, newUsername: "new_username" });
|
||||
});
|
||||
|
||||
it("updates the account row to the new username", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("username")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.username).toBe("new_username");
|
||||
});
|
||||
|
||||
it("inserts a row into account_username_change with the correct fields", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
const change = await DB.selectFrom("account_username_change")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.orderBy("timestamp", "desc")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(change).toMatchObject({
|
||||
user_id: userId,
|
||||
username: "new_username",
|
||||
previous_username: username,
|
||||
});
|
||||
});
|
||||
|
||||
it("only inserts one account_username_change row on a fresh account", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
const rows = await DB.selectFrom("account_username_change")
|
||||
.select("user_id")
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not affect other users' account rows", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
.select("username")
|
||||
.where("id", "=", other.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.username).toBe("other_user");
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row to the audit log on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "CHANGE_USERNAME")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "CHANGE_USERNAME",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("records the new username in the audit log input", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "CHANGE_USERNAME")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const input = action.input as Record<string, unknown>;
|
||||
|
||||
expect(input).toMatchObject({ newUsername: "new_username" });
|
||||
});
|
||||
|
||||
it("does not store the plaintext password in the audit log input", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": PASSWORD,
|
||||
});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "CHANGE_USERNAME")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const input = JSON.stringify(action.input);
|
||||
|
||||
expect(input).not.toContain(PASSWORD);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { PasswordCompare } from "#lib/auth/auth.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { NowISO8601 } from "#utils/time.js";
|
||||
import { CanChangeUsername } from "#utils/user.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_ChangeUsername = MakeAction(
|
||||
"CHANGE_USERNAME",
|
||||
async (taker, { newUsername, "!password": password }) => {
|
||||
if (taker.acct.username === newUsername) {
|
||||
throw new ExpectedErr(400, "New username is the same as the old username");
|
||||
}
|
||||
|
||||
await DB.transaction()
|
||||
.setIsolationLevel("serializable")
|
||||
.execute(async (txn) => {
|
||||
const pw = await txn
|
||||
.selectFrom("priv_account_credential")
|
||||
.select("password")
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (!pw) {
|
||||
throw new ExpectedErr(500, "User has no password?");
|
||||
}
|
||||
|
||||
const isPasswordValid = await PasswordCompare(password, pw.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new ExpectedErr(401, "Invalid password");
|
||||
}
|
||||
|
||||
const canChangeUsername = await CanChangeUsername(txn, taker.acct.id);
|
||||
|
||||
if (!canChangeUsername) {
|
||||
throw new ExpectedErr(400, "You can only change your username every 6 months.");
|
||||
}
|
||||
|
||||
await txn
|
||||
.insertInto("account_username_change")
|
||||
.values({
|
||||
user_id: taker.acct.id,
|
||||
username: newUsername,
|
||||
previous_username: taker.acct.username,
|
||||
timestamp: NowISO8601(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await txn
|
||||
.updateTable("account")
|
||||
.set({ username: newUsername })
|
||||
.where("id", "=", taker.acct.id)
|
||||
.execute();
|
||||
});
|
||||
|
||||
return { prevUsername: taker.acct.username, newUsername };
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ONE_SECOND } from "#lib/constants/time";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
countNotificationsForUser,
|
||||
getNotification,
|
||||
seedNotification,
|
||||
} from "./test-utils/notifications";
|
||||
import { ACTION_DeleteAllNotifications } from "./delete-all-notifications";
|
||||
|
||||
// ─── ACTION_DeleteAllNotifications ───────────────────────────────────────────
|
||||
|
||||
describe("ACTION_DeleteAllNotifications", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── Success path ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns { deletedCount: 0 } when the user has no notifications", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(result).toEqual({ deletedCount: 0 });
|
||||
});
|
||||
|
||||
it("returns the count of deleted notifications", async () => {
|
||||
await seedNotification({ userId });
|
||||
await seedNotification({ userId });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(result).toEqual({ deletedCount: 2 });
|
||||
});
|
||||
|
||||
it("removes the notifications from the DB", async () => {
|
||||
await seedNotification({ userId });
|
||||
await seedNotification({ userId });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(await countNotificationsForUser(userId)).toBe(0);
|
||||
});
|
||||
|
||||
it("deletes both read and unread notifications", async () => {
|
||||
await seedNotification({ userId, read: false });
|
||||
await seedNotification({ userId, read: true });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(await countNotificationsForUser(userId)).toBe(0);
|
||||
});
|
||||
|
||||
// ── Two-second cutoff ─────────────────────────────────────────────────────
|
||||
|
||||
it("does not delete notifications sent within the last two seconds", async () => {
|
||||
const rowId = await seedNotification({ userId, ageMs: 0 });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(await getNotification(rowId)).toBeDefined();
|
||||
expect(result.deletedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("deletes notifications older than two seconds", async () => {
|
||||
const rowId = await seedNotification({ userId, ageMs: ONE_SECOND * 3 });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(await getNotification(rowId)).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Isolation ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("does not delete other users' notifications", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
const rowId = await seedNotification({ userId: other.id });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(await getNotification(rowId)).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not count other users' notifications in deletedCount", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
await seedNotification({ userId: other.id });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_DeleteAllNotifications(taker, {});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "DELETE_ALL_NOTIFICATIONS")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "DELETE_ALL_NOTIFICATIONS",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { ONE_SECOND } from "#lib/constants/time.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
|
||||
export const ACTION_DeleteAllNotifications = MakeAction(
|
||||
"DELETE_ALL_NOTIFICATIONS",
|
||||
async (taker) => {
|
||||
// See mark-all-notifications-read.ts for an explanation of this two-second buffer.
|
||||
const cutoff = new Date(Date.now() - ONE_SECOND * 2).toISOString();
|
||||
|
||||
const rows = await DB.deleteFrom("notification")
|
||||
.where("sent_to", "=", taker.acct.id)
|
||||
.where("sent_at", "<", cutoff)
|
||||
.returning("row_id")
|
||||
.execute();
|
||||
|
||||
return { deletedCount: rows.length };
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { CDNDelete } from "#lib/cdn/cdn.js";
|
||||
import { GetProfileBannerURL } from "#lib/cdn/url-format.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_DeleteBanner = MakeAction("DELETE_BANNER", async (taker) => {
|
||||
const account = await DB.selectFrom("account")
|
||||
.select("custom_banner_location")
|
||||
.where("id", "=", taker.acct.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (!account.custom_banner_location) {
|
||||
throw new ExpectedErr(404, "You do not have a custom profile banner to delete.");
|
||||
}
|
||||
|
||||
await CDNDelete(GetProfileBannerURL(taker.acct.id, account.custom_banner_location));
|
||||
|
||||
await DB.updateTable("account")
|
||||
.set({ custom_banner_location: null })
|
||||
.where("id", "=", taker.acct.id)
|
||||
.execute();
|
||||
|
||||
return {};
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { CDNDelete } from "#lib/cdn/cdn.js";
|
||||
import { GetProfilePictureURL } from "#lib/cdn/url-format.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_DeletePfp = MakeAction("DELETE_PFP", async (taker) => {
|
||||
const account = await DB.selectFrom("account")
|
||||
.select("custom_pfp_location")
|
||||
.where("id", "=", taker.acct.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (!account.custom_pfp_location) {
|
||||
throw new ExpectedErr(404, "You do not have a custom profile picture to delete.");
|
||||
}
|
||||
|
||||
await CDNDelete(GetProfilePictureURL(taker.acct.id, account.custom_pfp_location));
|
||||
|
||||
await DB.updateTable("account")
|
||||
.set({ custom_pfp_location: null })
|
||||
.where("id", "=", taker.acct.id)
|
||||
.execute();
|
||||
|
||||
return {};
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import DB from "#services/pg/db.js";
|
||||
import { seedUser } from "#test-utils/pg-fixtures.js";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_InstallBuiltinClient } from "./install-builtin-client.js";
|
||||
import { ACTION_InstallBuiltinClient } from "./install-builtin-client";
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
import { IsUserAdmin } from "#utils/user.js";
|
||||
import { MakeAction } from "#lib/actions/actions";
|
||||
import DB from "#services/pg/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { IsUserAdmin } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_InstallBuiltinClient = MakeAction(
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ONE_SECOND } from "#lib/constants/time";
|
||||
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";
|
||||
|
||||
// ─── ACTION_MarkAllNotificationsRead ─────────────────────────────────────────
|
||||
|
||||
describe("ACTION_MarkAllNotificationsRead", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── Success path ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns { markedCount: 0 } when the user has no notifications", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
expect(result).toEqual({ markedCount: 0 });
|
||||
});
|
||||
|
||||
it("returns the count of notifications that were marked", async () => {
|
||||
await seedNotification({ userId });
|
||||
await seedNotification({ userId });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
expect(result).toEqual({ markedCount: 2 });
|
||||
});
|
||||
|
||||
it("marks unread notifications as read", async () => {
|
||||
const rowId = await seedNotification({ userId, read: false });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
const row = await getNotification(rowId);
|
||||
|
||||
expect(row?.read).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves already-read notifications read", async () => {
|
||||
const rowId = await seedNotification({ userId, read: true });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
const row = await getNotification(rowId);
|
||||
|
||||
expect(row?.read).toBe(true);
|
||||
});
|
||||
|
||||
// ── Two-second cutoff ─────────────────────────────────────────────────────
|
||||
|
||||
it("does not mark notifications sent within the last two seconds", async () => {
|
||||
// A notification sent just now (0ms ago) is within the cutoff.
|
||||
const rowId = await seedNotification({ userId, read: false, ageMs: 0 });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
const row = await getNotification(rowId);
|
||||
|
||||
expect(row?.read).toBe(false);
|
||||
expect(result.markedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("marks notifications older than two seconds", async () => {
|
||||
const rowId = await seedNotification({ userId, read: false, ageMs: ONE_SECOND * 3 });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
const row = await getNotification(rowId);
|
||||
|
||||
expect(row?.read).toBe(true);
|
||||
});
|
||||
|
||||
// ── Isolation ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("does not mark other users' notifications as read", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
const rowId = await seedNotification({ userId: other.id, read: false });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
const row = await getNotification(rowId);
|
||||
|
||||
expect(row?.read).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count other users' notifications in markedCount", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
await seedNotification({ userId: other.id, read: false });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
expect(result.markedCount).toBe(0);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_MarkAllNotificationsRead(taker, {});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "MARK_ALL_NOTIFICATIONS_READ")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "MARK_ALL_NOTIFICATIONS_READ",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { ONE_SECOND } from "#lib/constants/time.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
|
||||
export const ACTION_MarkAllNotificationsRead = MakeAction(
|
||||
"MARK_ALL_NOTIFICATIONS_READ",
|
||||
async (taker) => {
|
||||
// If a notification arrives at exactly the same moment the user clears their
|
||||
// inbox, they risk never seeing it. Excluding notifications sent in the last
|
||||
// two seconds makes that window negligibly small.
|
||||
const cutoff = new Date(Date.now() - ONE_SECOND * 2).toISOString();
|
||||
|
||||
const rows = await DB.updateTable("notification")
|
||||
.set({ read: true })
|
||||
.where("sent_to", "=", taker.acct.id)
|
||||
.where("sent_at", "<", cutoff)
|
||||
.returning("row_id")
|
||||
.execute();
|
||||
|
||||
return { markedCount: rows.length };
|
||||
},
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import DB from "#services/pg/db.js";
|
||||
import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures.js";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_ResendVerifyEmail } from "./resend-verify-email.js";
|
||||
import { ACTION_ResendVerifyEmail } from "./resend-verify-email";
|
||||
|
||||
// ─── ACTION_ResendVerifyEmail ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import { SendEmail } from "#lib/email/client.js";
|
||||
import { EmailFormatVerifyEmail } from "#lib/email/formats.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
import { MakeAction } from "#lib/actions/actions";
|
||||
import { SendEmail } from "#lib/email/client";
|
||||
import { EmailFormatVerifyEmail } from "#lib/email/formats";
|
||||
import DB from "#services/pg/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { log } from "bliss";
|
||||
|
||||
export const ACTION_ResendVerifyEmail = MakeAction("RESEND_VERIFY_EMAIL", async (taker, {}) => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ONE_SECOND } from "#lib/constants/time";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
export interface SeedNotifOpts {
|
||||
userId: number;
|
||||
title?: string;
|
||||
read?: boolean;
|
||||
/** Age of the notification in milliseconds. Defaults to 10 seconds (older than cutoff). */
|
||||
ageMs?: number;
|
||||
}
|
||||
|
||||
export async function seedNotification(opts: SeedNotifOpts) {
|
||||
const ageMs = opts.ageMs ?? ONE_SECOND * 10;
|
||||
const sentAt = new Date(Date.now() - ageMs).toISOString();
|
||||
|
||||
const row = await DB.insertInto("notification")
|
||||
.values({
|
||||
title: opts.title ?? "Test Notification",
|
||||
sent_to: opts.userId,
|
||||
sent_at: sentAt,
|
||||
read: opts.read ?? false,
|
||||
kind: "site_announcement",
|
||||
payload: {},
|
||||
})
|
||||
.returning("row_id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return row.row_id;
|
||||
}
|
||||
|
||||
export function getNotification(rowId: string) {
|
||||
return DB.selectFrom("notification").selectAll().where("row_id", "=", rowId).executeTakeFirst();
|
||||
}
|
||||
|
||||
export async function countNotificationsForUser(userId: number) {
|
||||
const rows = await DB.selectFrom("notification")
|
||||
.select("row_id")
|
||||
.where("sent_to", "=", userId)
|
||||
.execute();
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_UpdateUser } from "./update-user";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getAccountRow(userId: number) {
|
||||
return DB.selectFrom("account")
|
||||
.select([
|
||||
"about",
|
||||
"status",
|
||||
"sm_discord",
|
||||
"sm_twitter",
|
||||
"sm_github",
|
||||
"sm_steam",
|
||||
"sm_youtube",
|
||||
"sm_twitch",
|
||||
])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
// ─── ACTION_UpdateUser ────────────────────────────────────────────────────────
|
||||
|
||||
describe("ACTION_UpdateUser", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── Empty body guard ──────────────────────────────────────────────────────
|
||||
|
||||
it("throws 400 when no fields are provided", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(ACTION_UpdateUser(taker, {})).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("writes a BAD action row when no fields are provided", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(ACTION_UpdateUser(taker, {})).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
.where("kind", "=", "UPDATE_USER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action.result).toBe("BAD");
|
||||
});
|
||||
|
||||
// ── Success path ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns an empty object on success", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_UpdateUser(taker, { about: "Hello world" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("persists an updated about field", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { about: "New bio text" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.about).toBe("New bio text");
|
||||
});
|
||||
|
||||
it("persists an updated status field", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { status: "Playing IIDX" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.status).toBe("Playing IIDX");
|
||||
});
|
||||
|
||||
it("persists a null status field", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { status: null });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.status).toBeNull();
|
||||
});
|
||||
|
||||
it("persists an updated discord handle", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { discord: "myhandle#1234" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_discord).toBe("myhandle#1234");
|
||||
});
|
||||
|
||||
it("persists an updated github handle", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { github: "zkldi" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_github).toBe("zkldi");
|
||||
});
|
||||
|
||||
it("persists an updated twitter handle", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { twitter: "zkrdi" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_twitter).toBe("zkrdi");
|
||||
});
|
||||
|
||||
it("persists an updated twitch handle", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { twitch: "mystream" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_twitch).toBe("mystream");
|
||||
});
|
||||
|
||||
it("persists an updated steam id", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { steam: "76561198000000000" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_steam).toBe("76561198000000000");
|
||||
});
|
||||
|
||||
it("persists an updated youtube handle", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { youtube: "mychannel" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_youtube).toBe("mychannel");
|
||||
});
|
||||
|
||||
it("persists multiple fields updated in a single call", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, {
|
||||
about: "Multi-update bio",
|
||||
discord: "multiuser#0001",
|
||||
status: "Busy",
|
||||
});
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.about).toBe("Multi-update bio");
|
||||
expect(row.sm_discord).toBe("multiuser#0001");
|
||||
expect(row.status).toBe("Busy");
|
||||
});
|
||||
|
||||
// ── URL stripping ─────────────────────────────────────────────────────────
|
||||
|
||||
it("strips https://twitter.com/ prefix from twitter", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { twitter: "https://twitter.com/myhandle" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_twitter).toBe("myhandle");
|
||||
});
|
||||
|
||||
it("strips https://github.com/ prefix from github", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { github: "https://github.com/zkldi" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_github).toBe("zkldi");
|
||||
});
|
||||
|
||||
it("strips https://twitch.tv/ prefix from twitch", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { twitch: "https://twitch.tv/mystream" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_twitch).toBe("mystream");
|
||||
});
|
||||
|
||||
it("strips https://steamcommunity.com/id/ prefix from steam", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { steam: "https://steamcommunity.com/id/myid" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_steam).toBe("myid");
|
||||
});
|
||||
|
||||
it("strips the youtube.com/user/ prefix from youtube", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { youtube: "https://youtube.com/user/mychannel" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_youtube).toBe("mychannel");
|
||||
});
|
||||
|
||||
it("strips the youtube.com/channel/ prefix from youtube", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { youtube: "https://youtube.com/channel/UCmychannel" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_youtube).toBe("UCmychannel");
|
||||
});
|
||||
|
||||
it("strips the youtube.com/@ prefix from youtube", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { youtube: "https://youtube.com/@mychannel" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_youtube).toBe("mychannel");
|
||||
});
|
||||
|
||||
it("leaves a bare youtube handle untouched", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { youtube: "mychannel" });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_youtube).toBe("mychannel");
|
||||
});
|
||||
|
||||
it("does not strip urls from a null social media field", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { twitter: null });
|
||||
|
||||
const row = await getAccountRow(userId);
|
||||
|
||||
expect(row.sm_twitter).toBeNull();
|
||||
});
|
||||
|
||||
// ── Isolation ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("does not modify other users' account rows", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
const otherBefore = await getAccountRow(other.id);
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
await ACTION_UpdateUser(taker, { about: "Updated bio" });
|
||||
|
||||
const otherAfter = await getAccountRow(other.id);
|
||||
|
||||
expect(otherAfter).toEqual(otherBefore);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row to the audit log on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { about: "Audit test" });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "UPDATE_USER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "UPDATE_USER",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
|
||||
it("records the provided fields in the audit log input", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateUser(taker, { about: "Audit input test", discord: "disc#0001" });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
.where("kind", "=", "UPDATE_USER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const input = action.input as Record<string, unknown>;
|
||||
|
||||
expect(input).toMatchObject({ about: "Audit input test", discord: "disc#0001" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "#utils/misc.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_UpdateUser = MakeAction("UPDATE_USER", async (taker, body) => {
|
||||
const updates = {
|
||||
about: body.about,
|
||||
status: body.status,
|
||||
sm_discord: body.discord,
|
||||
sm_twitter: body.twitter,
|
||||
sm_github: body.github,
|
||||
sm_steam: body.steam,
|
||||
sm_youtube: body.youtube,
|
||||
sm_twitch: body.twitch,
|
||||
};
|
||||
|
||||
DeleteUndefinedProps(updates);
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new ExpectedErr(400, "No arguments provided to update user.");
|
||||
}
|
||||
|
||||
// Hack stuff for user experience.
|
||||
// In kt1, users would repeatedly mess up these fields.
|
||||
if (IsNonEmptyString(updates.sm_twitter)) {
|
||||
updates.sm_twitter = StripUrl("twitter.com/", updates.sm_twitter);
|
||||
}
|
||||
|
||||
if (IsNonEmptyString(updates.sm_github)) {
|
||||
updates.sm_github = StripUrl("github.com/", updates.sm_github);
|
||||
}
|
||||
|
||||
if (IsNonEmptyString(updates.sm_youtube)) {
|
||||
// youtube has THREE user urls lol
|
||||
updates.sm_youtube = StripUrl("youtube.com/user/", updates.sm_youtube);
|
||||
updates.sm_youtube = StripUrl("youtube.com/channel/", updates.sm_youtube);
|
||||
updates.sm_youtube = StripUrl("youtube.com/@", updates.sm_youtube);
|
||||
}
|
||||
|
||||
if (IsNonEmptyString(updates.sm_twitch)) {
|
||||
updates.sm_twitch = StripUrl("twitch.tv/", updates.sm_twitch);
|
||||
}
|
||||
|
||||
if (IsNonEmptyString(updates.sm_steam)) {
|
||||
updates.sm_steam = StripUrl("steamcommunity.com/id/", updates.sm_steam);
|
||||
}
|
||||
|
||||
await DB.updateTable("account").set(updates).where("id", "=", taker.acct.id).execute();
|
||||
|
||||
return {};
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import DB from "#services/pg/db.js";
|
||||
import { seedUser } from "#test-utils/pg-fixtures.js";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ANON_ACTION_ForgotPassword } from "./forgot-password.js";
|
||||
import { ANON_ACTION_ForgotPassword } from "./forgot-password";
|
||||
|
||||
// ─── ANON_ACTION_ForgotPassword ───────────────────────────────────────────────
|
||||
|
||||
@@ -161,7 +161,11 @@ describe("ANON_ACTION_ForgotPassword", () => {
|
||||
// ── Token isolation ────────────────────────────────────────────────────────
|
||||
|
||||
it("only inserts a token for the matching user, not for others", async () => {
|
||||
const other = await seedUser({ username: "other_user", email: "other@example.com", withCredential: true });
|
||||
const other = await seedUser({
|
||||
username: "other_user",
|
||||
email: "other@example.com",
|
||||
withCredential: true,
|
||||
});
|
||||
|
||||
await ANON_ACTION_ForgotPassword(taker, { email: user.email });
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { MakeAnonAction } from "#lib/actions/actions.js";
|
||||
import { SendEmail } from "#lib/email/client.js";
|
||||
import { EmailFormatResetPassword } from "#lib/email/formats.js";
|
||||
import { Env, ServerConfig } from "#lib/setup/config.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
import { GetUserWithIDGuaranteed } from "#utils/user.js";
|
||||
import { MakeAnonAction } from "#lib/actions/actions";
|
||||
import { SendEmail } from "#lib/email/client";
|
||||
import { EmailFormatResetPassword } from "#lib/email/formats";
|
||||
import { Env, ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import { ExpectedErr, log } from "bliss";
|
||||
|
||||
export const ANON_ACTION_ForgotPassword = MakeAnonAction(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import DB from "#services/pg/db.js";
|
||||
import { seedInvite, seedUser } from "#test-utils/pg-fixtures.js";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedInvite, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ANON_ACTION_Register } from "./register.js";
|
||||
import { ANON_ACTION_Register } from "./register";
|
||||
|
||||
// ─── ANON_ACTION_Register ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { MakeAnonAction } from "#lib/actions/actions.js";
|
||||
import { SendEmail } from "#lib/email/client.js";
|
||||
import { EmailFormatVerifyEmail } from "#lib/email/formats.js";
|
||||
import { Env, ServerConfig } from "#lib/setup/config.js";
|
||||
import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user.js";
|
||||
import { MakeAnonAction } from "#lib/actions/actions";
|
||||
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";
|
||||
import { ExpectedErr, log } from "bliss";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PasswordCompare } from "#lib/auth/auth.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { seedResetToken, seedUser } from "#test-utils/pg-fixtures.js";
|
||||
import { PasswordCompare } from "#lib/auth/auth";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedResetToken, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ANON_ACTION_ResetPassword } from "./reset-password.js";
|
||||
import { ANON_ACTION_ResetPassword } from "./reset-password";
|
||||
|
||||
// ─── ANON_ACTION_ResetPassword ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MakeAnonAction } from "#lib/actions/actions.js";
|
||||
import { HashPassword } from "#lib/auth/auth.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { GetTimeXHoursAgo } from "#utils/misc.js";
|
||||
import { ISO8601ToUnixMilliseconds } from "#utils/time.js";
|
||||
import { MakeAnonAction } from "#lib/actions/actions";
|
||||
import { HashPassword } from "#lib/auth/auth";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetTimeXHoursAgo } from "#utils/misc";
|
||||
import { ISO8601ToUnixMilliseconds } from "#utils/time";
|
||||
import { ExpectedErr, log } from "bliss";
|
||||
|
||||
export const ANON_ACTION_ResetPassword = MakeAnonAction(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import DB from "#services/pg/db.js";
|
||||
import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures.js";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ANON_ACTION_VerifyEmail } from "./verify-email.js";
|
||||
import { ANON_ACTION_VerifyEmail } from "./verify-email";
|
||||
|
||||
// ─── ANON_ACTION_VerifyEmail ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MakeAnonAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { MakeAnonAction } from "#lib/actions/actions";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ANON_ACTION_VerifyEmail = MakeAnonAction("VERIFY_EMAIL", async (_taker, { code }) => {
|
||||
|
||||
@@ -36,6 +36,81 @@ export const ActionSignatures = {
|
||||
input: z.object({}),
|
||||
output: z.object({}),
|
||||
},
|
||||
CHANGE_USERNAME: {
|
||||
input: z.object({
|
||||
newUsername: z.string().regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u),
|
||||
"!password": z.string().min(8),
|
||||
}),
|
||||
output: z.object({
|
||||
prevUsername: z.string(),
|
||||
newUsername: z.string(),
|
||||
}),
|
||||
},
|
||||
CHANGE_PASSWORD: {
|
||||
input: z.object({
|
||||
"!oldPassword": z.string().min(8),
|
||||
"!password": z.string().min(8),
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
CHANGE_EMAIL: {
|
||||
input: z.object({
|
||||
email: z.email(),
|
||||
"!password": z.string().min(8),
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
CHANGE_PFP: {
|
||||
input: z.object({
|
||||
"!fileBuffer": z.instanceof(Buffer),
|
||||
fileMimetype: z.string(),
|
||||
}),
|
||||
output: z.object({
|
||||
contentHash: z.string(),
|
||||
}),
|
||||
},
|
||||
CHANGE_BANNER: {
|
||||
input: z.object({
|
||||
"!fileBuffer": z.instanceof(Buffer),
|
||||
fileMimetype: z.string(),
|
||||
}),
|
||||
output: z.object({
|
||||
contentHash: z.string(),
|
||||
}),
|
||||
},
|
||||
DELETE_PFP: {
|
||||
input: z.object({}),
|
||||
output: z.object({}),
|
||||
},
|
||||
DELETE_BANNER: {
|
||||
input: z.object({}),
|
||||
output: z.object({}),
|
||||
},
|
||||
UPDATE_USER: {
|
||||
input: z.object({
|
||||
about: z.string().optional(),
|
||||
status: z.string().nullable().optional(),
|
||||
discord: z.string().nullable().optional(),
|
||||
twitter: z.string().nullable().optional(),
|
||||
github: z.string().nullable().optional(),
|
||||
steam: z.string().nullable().optional(),
|
||||
youtube: z.string().nullable().optional(),
|
||||
twitch: z.string().nullable().optional(),
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
MARK_ALL_NOTIFICATIONS_READ: {
|
||||
input: z.object({}),
|
||||
output: z.object({
|
||||
markedCount: z.number(),
|
||||
}),
|
||||
},
|
||||
DELETE_ALL_NOTIFICATIONS: {
|
||||
input: z.object({}),
|
||||
output: z.object({
|
||||
deletedCount: z.number(),
|
||||
}),
|
||||
},
|
||||
} satisfies Record<string, ActionSignature>;
|
||||
|
||||
export const AnonActionSignatures = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db.js";
|
||||
import DB from "#services/pg/db";
|
||||
import nodeFetch from "#utils/fetch";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { CreateURLWithParams } from "#utils/url";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client.js";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import _ from "lodash";
|
||||
import { type APIPermissions, type TachiAPIClientDocument } from "tachi-common";
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { GetClientByID } from "#utils/queries/api-clients.js";
|
||||
import { GetFirstAdmin } from "#utils/user.js";
|
||||
import { GetClientByID } from "#utils/queries/api-clients";
|
||||
import { GetFirstAdmin } from "#utils/user";
|
||||
import fjsh from "fast-json-stable-hash";
|
||||
|
||||
type DefaultClients = Array<Omit<TachiAPIClientDocument, "author" | "clientSecret">>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Response } from "express";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import fs from "fs";
|
||||
import mkdirp from "mkdirp";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type Selection } from "kysely";
|
||||
import {
|
||||
type AnyClasses,
|
||||
type GPTString,
|
||||
type ProfileRatingAlgorithms,
|
||||
type UserGameStats,
|
||||
V3ToGamePT,
|
||||
} from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
export const SELECT_GAME_STATS = [
|
||||
"game_stats.user_id",
|
||||
"game_stats.game",
|
||||
"game_stats.ratings",
|
||||
"game_stats.classes",
|
||||
] as const;
|
||||
|
||||
export function ToGameStatsDocument(
|
||||
row: Selection<Database, "game_stats", (typeof SELECT_GAME_STATS)[number]>,
|
||||
): UserGameStats {
|
||||
const { game, playtype } = V3ToGamePT(row.game);
|
||||
|
||||
return {
|
||||
userID: row.user_id,
|
||||
game,
|
||||
playtype,
|
||||
ratings: row.ratings as Partial<Record<ProfileRatingAlgorithms[GPTString], number | null>>,
|
||||
classes: row.classes as AnyClasses,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthLevelToInt } from "#utils/conversion.js";
|
||||
import { ISO8601ToUnixMilliseconds } from "#utils/time.js";
|
||||
import { AuthLevelToInt } from "#utils/conversion";
|
||||
import { ISO8601ToUnixMilliseconds } from "#utils/time";
|
||||
import { type Selection } from "kysely";
|
||||
import { type UserBadges, type UserDocument } from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env, ServerConfig } from "#lib/setup/config";
|
||||
import bunyan from "bunyan";
|
||||
import nodemailer, { type SentMessageInfo, type Transporter } from "nodemailer";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GameGroup, integer, Playtype } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BMSTableHead, RawBMSTableEntry } from "bms-table-loader";
|
||||
import type { Request, Response } from "express-serve-static-core";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { GetRivalUsers } from "#lib/rivals/rivals";
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChartDocument, integer, Playtypes } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { PoyashiBPI } from "rg-stats";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type http from "http";
|
||||
import type https from "https";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { CloseScoreImportQueue } from "#lib/score-import/worker/queue";
|
||||
import { monkDB } from "#services/mongo/db";
|
||||
import { ClosePgConnection } from "#services/pg/db.js";
|
||||
import { ClosePgConnection } from "#services/pg/db";
|
||||
import { CloseRedisConnection } from "#services/redis/redis";
|
||||
|
||||
export function HandleSIGTERMGracefully(instance?: http.Server | https.Server) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ImportDocument } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import {
|
||||
CheckAndSetOngoingImportLock,
|
||||
UnsetOngoingImportLock,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { UserDocument } from "tachi-common";
|
||||
|
||||
import { ONE_MONTH } from "#lib/constants/time";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { PullDatabaseSeeds } from "#lib/seeds/repo";
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChartDocument } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { PullDatabaseSeeds } from "#lib/seeds/repo";
|
||||
import { WrapScriptPromise } from "#utils/misc";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import type { FilterQuery } from "mongodb";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
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";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Attempt to deoprhan lost scores.
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans";
|
||||
import { WrapScriptPromise } from "#utils/misc";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import { DedupeArr } from "#utils/misc";
|
||||
import { Queue, Worker } from "bullmq";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UserGameStats, UserGameStatsSnapshotDocument } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetMillisecondsSince } from "#utils/misc";
|
||||
import { GetAllRankings } from "#utils/user";
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
Versions,
|
||||
} from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { BacksyncCollection, PullDatabaseSeeds } from "#lib/seeds/repo";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { RecalcAllScores } from "#utils/calculations/recalc-scores";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { BacksyncCollection } from "#lib/seeds/repo";
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ChartDocument, Difficulties, integer } from "tachi-common";
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { BacksyncCollection } from "#lib/seeds/repo";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { RecalcAllScores } from "#utils/calculations/recalc-scores";
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetNextCounterValue } from "#utils/db";
|
||||
import { DedupeArr } from "#utils/misc";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BulkWriteUpdateOneOperation } from "mongodb";
|
||||
|
||||
import { SetRivalsFailReasons } from "#lib/constants/err-codes";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { SendSetRivalNotification } from "#lib/notifications/notification-wrappers";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { ClassConfigs, Classes, GPTString, integer } from "tachi-common";
|
||||
import type { DerivedClassConfig, ProvidedClassConfig } from "tachi-common/types/game-config-utils";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type {
|
||||
ImportTypeContextMap,
|
||||
ImportTypeDataMap,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ImportTypes, UserDocument } from "tachi-common";
|
||||
|
||||
import { type KtLogger, log } from "#lib/log/log.js";
|
||||
import { type KtLogger, log } from "#lib/log/log";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
|
||||
export function CreateScoreLogger(
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
UnsuccessfulAPIResponse,
|
||||
} from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
|
||||
import type { ParserArguments } from "../worker/types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { GameGroup, GoalDocument, GoalSubscriptionDocument, integer } from "tachi-common";
|
||||
|
||||
import { EvaluateGoalForUser, GetRelevantGoals } from "#lib/targets/goals";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { ONE_DAY, ONE_HOUR } from "#lib/constants/time";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { FilterQuery } from "mongodb";
|
||||
import type { GameGroup, ImportTypes, integer } from "tachi-common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { BulkWriteUpdateOneOperation, FilterQuery, SortOptionObject } from "mongodb";
|
||||
|
||||
import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetChartForIDGuaranteed } from "#utils/db";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
|
||||
import { EmitWebhookEvent } from "#lib/webhooks/webhooks";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { BulkWriteUpdateOneOperation } from "mongodb";
|
||||
import type {
|
||||
GameGroup,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ImportDocument, ImportTypes, integer } from "tachi-common";
|
||||
|
||||
import { JOB_RETRY_COUNT } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import { Sleep } from "#utils/misc";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GPTDerivers } from "#game-implementations/types";
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { MetricValue } from "tachi-common/types/metrics";
|
||||
|
||||
import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
|
||||
import {
|
||||
type ChartDocument,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { integer, ScoreDocument } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
const MAX_PIPELINE_LENGTH = 500;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import fjsh from "fast-json-stable-hash";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ScoreImportJob } from "#lib/score-import/worker/types";
|
||||
|
||||
import { AppendLogCtx, type KtLogger } from "#lib/log/log.js";
|
||||
import { AppendLogCtx, type KtLogger } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { ClassToObject } from "#utils/misc";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ONE_HOUR } from "#lib/constants/time";
|
||||
import { AppendLogCtx, type KtLogger, log } from "#lib/log/log.js";
|
||||
import { AppendLogCtx, type KtLogger, log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { GetChartForIDGuaranteed } from "#utils/db";
|
||||
import { GetScoresFromSession } from "#utils/session";
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ImportTypes } from "tachi-common";
|
||||
|
||||
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn";
|
||||
import { GetScoreImportInputURL } from "#lib/cdn/url-format";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { log } from "#lib/log/log";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
import type ScoreImportFatalError from "../score-importing/score-import-error";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { ClassDelta, GameGroup, integer, Playtype, UserGameStats } from "tachi-common";
|
||||
|
||||
import { CreateGameSettings } from "#lib/game-settings/create-game-settings";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { GetKaiAuthGuaranteed } from "#utils/queries/auth";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { EmptyObject } from "#utils/types";
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
|
||||
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { CGCardInfo } from "tachi-common";
|
||||
|
||||
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KtLogger } from "#lib/log/log.js";
|
||||
import type { KtLogger } from "#lib/log/log";
|
||||
import type { KaiAuthDocument } from "tachi-common";
|
||||
|
||||
import nodeFetch from "#utils/fetch";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user