diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..978fcd2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +name: Tests & Coverage + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Test & Coverage + runs-on: ubuntu-latest + permissions: + contents: read + actions: read # pento/lcov-coverage-check: list workflow runs and download baseline artifact + pull-requests: write # pento/lcov-coverage-check: post/update coverage comment on PRs + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install tools (Bun + Gitleaks via mise) + run: ./bin/mise install + + - name: Install dependencies + run: ./bin/mise run install + + - name: Type check + run: ./bin/mise run typecheck + + - name: Run tests with coverage + run: ./bin/mise run test:coverage + + # Stores lcov-baseline artifact on main; retrieves it and posts a coverage + # diff comment on PRs. Falls back to summary-only on first run or fork PRs. + - name: Report coverage + uses: pento/lcov-coverage-check@972428b8d5b3fbd8230df23ca4190ecce29979cd # v3.1.0 + with: + path: "src/" + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.mise.toml b/.mise.toml index 0dd9db9..8b6d96a 100644 --- a/.mise.toml +++ b/.mise.toml @@ -30,6 +30,14 @@ run = "bun run build" description = "Type-check TypeScript source without emitting files" run = "bun run typecheck" +[tasks.test] +description = "Run all tests" +run = "bun test" + +[tasks."test:coverage"] +description = "Run all tests with LCOV coverage report (output: coverage/lcov.info)" +run = "bun test --coverage --coverage-reporter=lcov" + [tasks."prod:build"] description = "Build minified production executable" run = "bun run build" diff --git a/IDEAS/TODO.md b/IDEAS/TODO.md new file mode 100644 index 0000000..0a0ac16 --- /dev/null +++ b/IDEAS/TODO.md @@ -0,0 +1,53 @@ +# Ideas & TODO + +Feature ideas and potential improvements for the bot. + +Priority is rated from an end-user perspective: +- 🔴 **High** — directly improves core daily usage; most users would benefit immediately +- 🟡 **Medium** — noticeable improvement but not critical to the main workflow +- 🟢 **Low** — nice to have, niche use case, or primarily an admin/backend concern + +--- + +## Code & Redemption + +- 🟡 **`/stats` command** — Server-wide stats: total codes found, redemption counts, registered user count, aggregate loot earned. Data already exists in DB. +- 🟡 **Loot summary in `/codes`** — Show aggregate totals (gold, rubies, equipment) across all redeemed codes. `loot_detail` JSON is already stored per row. +- 🟢 **Code source tracking** — Store which channel/message ID a code was found in. Useful for auditing and showing users where codes originated. +- 🔴 **Multi-channel scanning** — `DISCORD_CHANNEL_ID` is a single channel. Support a comma-separated list or a `/setchannels` admin command. If codes get posted in other channels, users miss them entirely. +- 🟢 **Auto-purge expired pending codes** — Pending codes that fail globally should be cleaned up automatically; right now they stay in `pending_codes` forever. +- 🔴 **Scheduled catchup** — Run catchup automatically for all autoredeem-enabled users on a configurable timer (e.g. daily) as a safety net for missed codes. Prevents silently missing free loot. + +--- + +## Notifications & UX + +- 🔴 **DM notifications for new codes** — Opt-in per user to get a DM when a code is *detected*, independent of whether autoredeem is on. Users who want to redeem manually currently have no way to know a code appeared. +- 🔴 **Notification preferences command** — Let users configure: DM on success, DM on failure, DM when a code they haven't claimed is about to expire. High value as DM spam is a common complaint with bots. +- 🔴 **Better blacksmith UX** — `/blacksmith` requires a raw hero ID. Inventory data from `getUserDetails` includes hero names — show a hero picker or list. Current UX is nearly unusable without looking up IDs externally. +- 🟢 **Paginated `/codes`** — Replace the `count` cap (currently max 20) with Discord prev/next buttons for a cleaner experience. + +--- + +## Admin & Operations + +- 🟢 **`/admin` subcommand group** — Consolidate admin actions under one command: + - View recent audit log entries + - Force-expire or remove a specific code + - Remove a specific pending code + - View registered user count +- 🟢 **Config command** — Set bot config (scan channels, log level, autoredeem default) at runtime without restarting. + +--- + +## API / Game Features + +- 🟡 **`/buy` command** — `purchaseChests()` is already implemented in `idleChampionsApi.ts`. Just needs a command wired up. Convenient to do from Discord without opening the game. +- 🔴 **`/heroes` command** — List the user's champions with levels and upgrade costs from the player data response. Directly unblocks the blacksmith UX problem above. +- 🟡 **`/export` command** — Export redeemed code history as a CSV file attachment (`AttachmentBuilder`). Useful for personal tracking. + +--- + +## Quality of Life + +- ✅ **`/deleteaccount` command** *(implemented)* — Let users remove their credentials and history from the DB (GDPR-friendly self-service). diff --git a/README.md b/README.md index bad242f..19b99c1 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ See [docker-compose.example.yml](docker-compose.example.yml) for all available c ## ✨ Features -- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help` +- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help` - 🔄 **Auto Code Detection** - Scans Discord messages for codes automatically - ⏮️ **Message History Backfill** - Recover missed codes from message history with built-in rate limiting - 🔁 **Catch Up** - Redeem all known valid codes in one command (great for new members) @@ -93,6 +93,7 @@ See [docker-compose.example.yml](docker-compose.example.yml) for all available c - 📦 **Chest Management** - Open chests and view loot - ⚒️ **Blacksmith** - Upgrade heroes with contracts - 📊 **Inventory** - View gold, rubies, equipment, and progress +- 🗑️ **Account Deletion** - GDPR-friendly self-service data removal (`/deleteaccount`) - 💾 **Secure Storage** - SQLite database keeps credentials safe and local - 👥 **Multi-User** - Each user manages their own account - ⚡ **Fast** - Built on Bun for 3-4x performance vs Node.js diff --git a/docs/api-reference.md b/docs/api-reference.md index 153dd32..ae1c36a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -29,7 +29,7 @@ The Discord bot responds to slash commands in Discord channels. All commands ret **Response Format**: Discord Embeds (rich message format) or Ephemeral Text -**Total commands**: 11 (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help`) +**Total commands**: 12 (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help`) --- @@ -708,7 +708,53 @@ Bot (ephemeral): ⏸️ Auto-Redeem Disabled --- -### 11. `/help` +### 11. `/deleteaccount` + +Permanently delete all data the bot holds about the invoking user. + +**Invocation**: +``` +/deleteaccount +``` + +**Parameters**: None + +**Flow**: +1. Bot checks whether credentials or backfill history exist for the user. If none are found, responds with a warning and exits. +2. Bot checks whether a backfill the user initiated is currently in progress; if so, refuses deletion until it completes. +3. Bot sends an ephemeral embed with Yes / Cancel buttons (30-second timeout). +4. If the user clicks **Yes, delete everything**: credentials, all redeemed code records, audit log entries, and backfill operation history are permanently removed. A summary is shown. +5. If the user clicks **Cancel** or the timeout elapses: no data is changed. + +**Data removed on confirmation**: +- `users` row (credentials, server, autoredeem preference) +- All `redeemed_codes` rows for the user +- All `audit_log` rows for the user +- All `backfill_operations` rows for the user + +**Error Codes**: + +| Code | Meaning | Resolution | +|------|---------|------------| +| `NO_ACCOUNT` | No credentials or backfill history stored for this user | Nothing to delete | +| `BACKFILL_IN_PROGRESS` | A backfill the user initiated is still running | Wait for the backfill to complete, then retry | + +**Example**: +``` +User: /deleteaccount +Bot (ephemeral): ⚠️ Delete Account — Are you sure? + [Yes, delete everything] [Cancel] + +User: clicks Yes +Bot (ephemeral): ✅ Account Deleted + • Credentials deleted + • 42 code record(s) deleted + • Audit log entries deleted +``` + +--- + +### 12. `/help` Display command reference and usage instructions. @@ -752,6 +798,8 @@ Display command reference and usage instructions. │ Toggle automatic code redemption │ │ /backfill [channel:] │ │ Recover codes from message history │ +│ /deleteaccount │ +│ Permanently delete all your stored data │ │ /help │ │ Show this message │ │ │ @@ -948,6 +996,7 @@ The bot implements request throttling: | `/open` | 1 per 3 seconds | Per user | | `/blacksmith` | 1 per 3 seconds | Per user | | `/backfill` | 1 concurrent | Per guild | +| `/deleteaccount` | Unlimited | Per user | | Code detection | Per message | Automatic | --- @@ -1195,7 +1244,7 @@ Bot (embed): ## OSPS-SA-02.01 Compliance ✅ **Software Interfaces Documented**: -- 11 slash commands with parameters, responses, error codes +- 12 slash commands with parameters, responses, error codes - Message event detection with pattern matching - Response formats and data structures - Error handling and recovery procedures diff --git a/docs/full-documentation.md b/docs/full-documentation.md index b7e8446..b1e327e 100644 --- a/docs/full-documentation.md +++ b/docs/full-documentation.md @@ -4,7 +4,7 @@ A Discord bot that automatically scans for and redeems Idle Champions promo code ## Features -- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help` +- 🤖 **Slash Commands** - `/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help` - 🔄 **Auto Code Detection** - Scans Discord messages for codes automatically - 🤖 **Auto-Redeem Toggle** - Enable or disable automatic code redemption per user (`/autoredeem`) - ⏮️ **Message History Backfill** - Recover missed codes from message history (protected with rate limiting) @@ -13,6 +13,7 @@ A Discord bot that automatically scans for and redeems Idle Champions promo code - ⚒️ **Blacksmith** - Upgrade heroes with contracts - 📊 **Inventory** - View gold, rubies, equipment, and progress - 💾 **Secure Storage** - SQLite database keeps credentials safe and local +- 🗑️ **Account Deletion** - GDPR-friendly self-service removal of all stored data (`/deleteaccount`) - 👥 **Multi-User** - Each user manages their own account - ⚡ **Fast** - Built on Bun for 3-4x performance vs Node.js @@ -71,6 +72,7 @@ brew install mise | `/codes [count:]` | Show your redeemed codes history (last 10) | | `/makepublic code:` | Share one of your redeemed codes with other users | | `/backfill [channel:]` | Recover missed codes from message history | +| `/deleteaccount` | Permanently delete all your stored data (GDPR) | | `/help` | Show all commands | ### Setup & Authentication @@ -202,6 +204,22 @@ Toggle whether the bot automatically redeems new codes when they are detected in - **Behaviour when disabled:** Codes are still detected and stored, but you must use `/redeem` or `/catchup` to claim them manually - **Example:** `/autoredeem enabled:off` +### Account Management + +#### `/deleteaccount` + +Permanently and irreversibly delete all data the bot holds about you. Requires an explicit confirmation step to prevent accidents. + +- **No parameters required** +- **What is deleted:** + - Your Idle Champions credentials (user ID + hash) + - Your full code redemption history + - Your audit log entries + - Your backfill operation history +- **Confirmation:** A Yes / Cancel button prompt appears with a 30-second timeout — no action is taken unless you click **Yes, delete everything** +- **After deletion:** You will need to run `/setup` again to use the bot +- **Example:** `/deleteaccount` + ### Help #### `/help` diff --git a/docs/structure.md b/docs/structure.md index 8b553a3..ac2c669 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -28,7 +28,7 @@ idle-code-redeemer/ │ ├── bot/ ← Discord bot (ACTIVE) │ │ ├── bot.ts ← Main Discord client & event handlers │ │ ├── api/ ← Game server API client -│ │ ├── commands/ ← Slash command handlers (11 commands) +│ │ ├── commands/ ← Slash command handlers (12 commands) │ │ ├── database/ ← Database managers & Drizzle schema │ │ │ ├── db.ts ← Drizzle connection & migrate() │ │ │ ├── userManager.ts @@ -73,7 +73,7 @@ idle-code-redeemer/ - **[src/bot/bot.ts](../src/bot/bot.ts)** - Discord client initialization, event handlers, command routing - **[src/bot/api/idleChampionsApi.ts](../src/bot/api/idleChampionsApi.ts)** - Game server API client with query-parameter format -### Commands (11 slash commands) +### Commands (12 slash commands) - **[src/bot/commands/setup.ts](../src/bot/commands/setup.ts)** - `/setup user_id: user_hash:` - **[src/bot/commands/redeem.ts](../src/bot/commands/redeem.ts)** - `/redeem code:` @@ -85,6 +85,7 @@ idle-code-redeemer/ - **[src/bot/commands/codes.ts](../src/bot/commands/codes.ts)** - `/codes [count:]` (view redeemed codes history) - **[src/bot/commands/makepublic.ts](../src/bot/commands/makepublic.ts)** - `/makepublic code:` (share codes with other users) - **[src/bot/commands/backfill.ts](../src/bot/commands/backfill.ts)** - `/backfill [channel:]` (recover missed codes) +- **[src/bot/commands/deleteaccount.ts](../src/bot/commands/deleteaccount.ts)** - `/deleteaccount` (permanently delete all stored user data, GDPR-friendly) - **[src/bot/commands/help.ts](../src/bot/commands/help.ts)** - `/help` ### Database diff --git a/docs/system-design.md b/docs/system-design.md index e068c94..241fa0e 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -92,7 +92,7 @@ The bot reads promo codes from Discord messages, redeems them via the Idle Champ **Role**: End-user interacting with the bot **Actions**: -- Submit slash commands (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/help`) +- Submit slash commands (`/setup`, `/redeem`, `/catchup`, `/autoredeem`, `/inventory`, `/open`, `/blacksmith`, `/codes`, `/makepublic`, `/backfill`, `/deleteaccount`, `/help`) - Send messages containing promo codes in the monitored channel - Receive responses and error messages from the bot @@ -508,6 +508,7 @@ All handlers follow pattern: | `/codes` | codes.ts | View code history | Embed | | `/makepublic` | makepublic.ts | Share codes with users | Message | | `/backfill` | backfill.ts | Scan message history | Embed | +| `/deleteaccount` | deleteaccount.ts | Delete all user data (GDPR) | Ephemeral | | `/help` | help.ts | Command reference | Embed | --- diff --git a/src/bot/bot.ts b/src/bot/bot.ts index 6bb16bf..9eac49e 100644 --- a/src/bot/bot.ts +++ b/src/bot/bot.ts @@ -11,6 +11,7 @@ import logger from './utils/logger'; import { apiRequestLogger } from './utils/apiRequestLogger'; import * as backfillCommand from './commands/backfill'; import * as blacksmithCommand from './commands/blacksmith'; +import * as deleteaccountCommand from './commands/deleteaccount'; import * as catchupCommand from './commands/catchup'; import * as codesCommand from './commands/codes'; import * as autoredeemCommand from './commands/autoredeem'; @@ -54,6 +55,7 @@ const commands = [ blacksmithCommand, catchupCommand, codesCommand, + deleteaccountCommand, helpCommand, inventoryCommand, makepublicCommand, diff --git a/src/bot/commands/deleteaccount.ts b/src/bot/commands/deleteaccount.ts new file mode 100644 index 0000000..b22c50d --- /dev/null +++ b/src/bot/commands/deleteaccount.ts @@ -0,0 +1,165 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + MessageFlags, +} from 'discord.js'; +import { userManager } from '../database/userManager'; +import { codeManager } from '../database/codeManager'; +import { auditManager } from '../database/auditManager'; +import { backfillManager } from '../database/backfillManager'; +import logger from '../utils/logger'; + +export const data = new SlashCommandBuilder() + .setName('deleteaccount') + .setDescription('Permanently delete all your data from this bot (credentials, code history, audit log)'); + +export async function execute(interaction: ChatInputCommandInteraction) { + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const hasCredentials = await userManager.hasCredentials(interaction.user.id); + const hasBackfillOps = await backfillManager.hasUserBackfillOperations(interaction.user.id); + + if (!hasCredentials && !hasBackfillOps) { + const embed = new EmbedBuilder() + .setColor(0xffaa00) + .setTitle('⚠️ No Account Found') + .setDescription('You have no stored data in this bot — nothing to delete.'); + + await interaction.editReply({ embeds: [embed] }); + return; + } + + const confirmEmbed = new EmbedBuilder() + .setColor(0xff0000) + .setTitle('⚠️ Delete Account — Are you sure?') + .setDescription( + 'This will **permanently and irreversibly** delete all of your data:\n\n' + + '• Your Idle Champions credentials\n' + + '• Your full code redemption history\n' + + '• Your audit log entries\n' + + '• Your backfill operation history\n\n' + + 'You will need to run `/setup` again to use the bot after this.' + ) + .setFooter({ text: 'This action cannot be undone. Confirmation expires in 30 seconds.' }); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('deleteaccount_confirm') + .setLabel('Yes, delete everything') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('deleteaccount_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + + const reply = await interaction.editReply({ embeds: [confirmEmbed], components: [row] }); + + let buttonInteraction; + try { + buttonInteraction = await reply.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === interaction.user.id, + time: 30_000, + }); + } catch { + // Timed out — disable the buttons + const disabledRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('deleteaccount_confirm') + .setLabel('Yes, delete everything') + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('deleteaccount_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + .setDisabled(true) + ); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x888888) + .setTitle('⏱️ Confirmation Timed Out') + .setDescription('No response received within 30 seconds. Account deletion cancelled.'), + ], + components: [disabledRow], + }); + return; + } + + await buttonInteraction.deferUpdate(); + + if (buttonInteraction.customId === 'deleteaccount_cancel') { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00aa00) + .setTitle('✅ Cancelled') + .setDescription('Account deletion cancelled. Your data has not been changed.'), + ], + components: [], + }); + return; + } + + // Refuse deletion if the user has an active backfill — completing it would + // try to write to rows that no longer exist after account deletion. + if (await backfillManager.hasUserActiveBackfill(interaction.user.id)) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xffaa00) + .setTitle('⚠️ Backfill In Progress') + .setDescription( + 'A backfill operation you initiated is currently running. ' + + 'Please wait for it to complete before deleting your account.' + ), + ], + components: [], + }); + return; + } + + // Perform deletion — order matters for FK constraints: + // pending_codes.discord_id → users.discord_id (no cascade), so clear it first + await codeManager.clearPendingCodes(interaction.user.id); + const deletedCodesCount = await codeManager.deleteUserRedeemedCodes(interaction.user.id); + await auditManager.deleteUserAuditLog(interaction.user.id); + const deletedBackfillCount = await backfillManager.deleteUserBackfillOperations(interaction.user.id); + await userManager.deleteCredentials(interaction.user.id); + + // Log a non-identifying event — the user's credentials and ID are now gone + logger.info( + `[DELETE ACCOUNT] Account deletion completed. Removed ${deletedCodesCount} redeemed code record(s) and ${deletedBackfillCount} backfill operation record(s).` + ); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00aa00) + .setTitle('✅ Account Deleted') + .setDescription( + 'All your data has been permanently removed:\n\n' + + `• Credentials deleted\n` + + `• ${deletedCodesCount} code record(s) deleted\n` + + '• Audit log entries deleted\n' + + `• ${deletedBackfillCount} backfill operation record(s) deleted\n\n` + + 'If you want to use the bot again in the future, simply run `/setup`.' + ), + ], + components: [], + }); + } catch (error) { + logger.error('[DELETE ACCOUNT] Error:', error); + await interaction.editReply({ + content: '❌ An error occurred while deleting your account. Please try again later.', + }); + } +} diff --git a/src/bot/commands/help.ts b/src/bot/commands/help.ts index 53e0fa1..dec16b5 100644 --- a/src/bot/commands/help.ts +++ b/src/bot/commands/help.ts @@ -69,6 +69,12 @@ export async function execute(interaction: ChatInputCommandInteraction) { name: '⏮️ Backfill', value: '`/backfill [channel:]`\nRecover missed codes from message history (admin only).', inline: false, + }, + { + name: '🗑️ Delete Account', + value: + '`/deleteaccount`\nPermanently delete all your stored data (credentials, code history, audit log). Requires confirmation.', + inline: false, } ) .addFields( diff --git a/src/bot/database/auditManager.test.ts b/src/bot/database/auditManager.test.ts new file mode 100644 index 0000000..02d5d2b --- /dev/null +++ b/src/bot/database/auditManager.test.ts @@ -0,0 +1,219 @@ +import { describe, test, expect, beforeAll, beforeEach } from 'bun:test'; +import { db, initializeDatabase } from './db'; +import { auditManager } from './auditManager'; +import { users, redeemedCodes, pendingCodes, auditLog } from './schema/index'; + +const USER_A = 'audit-user-a'; +const USER_B = 'audit-user-b'; + +beforeAll(() => { + initializeDatabase(); +}); + +beforeEach(() => { + // Clear in FK-safe order + db.delete(pendingCodes).run(); + db.delete(auditLog).run(); + db.delete(redeemedCodes).run(); + db.delete(users).run(); + // Seed users required by FK on audit_log.discord_id + db.insert(users) + .values([ + { discordId: USER_A, userId: '111', userHash: 'hash-a' }, + { discordId: USER_B, userId: '222', userHash: 'hash-b' }, + ]) + .run(); +}); + +// --------------------------------------------------------------------------- +// logAction +// --------------------------------------------------------------------------- +describe('logAction', () => { + test('inserts an audit log entry for a user', async () => { + await auditManager.logAction(USER_A, 'TEST_ACTION'); + const rows = db.select().from(auditLog).all(); + expect(rows).toHaveLength(1); + expect(rows[0]!.discordId).toBe(USER_A); + expect(rows[0]!.action).toBe('TEST_ACTION'); + expect(rows[0]!.details).toBeNull(); + }); + + test('stores JSON-serialised details', async () => { + await auditManager.logAction(USER_A, 'REDEEM', { code: 'ABCD1234EFGH', status: 'Success' }); + const rows = db.select().from(auditLog).all(); + expect(rows[0]!.details).toBe(JSON.stringify({ code: 'ABCD1234EFGH', status: 'Success' })); + }); + + test('accepts null discordId for system-level actions', async () => { + await auditManager.logAction(null, 'SYSTEM_START'); + const rows = db.select().from(auditLog).all(); + expect(rows[0]!.discordId).toBeNull(); + expect(rows[0]!.action).toBe('SYSTEM_START'); + }); + + test('inserts multiple entries independently', async () => { + await auditManager.logAction(USER_A, 'ACTION_1'); + await auditManager.logAction(USER_A, 'ACTION_2'); + await auditManager.logAction(USER_B, 'ACTION_3'); + expect(db.select().from(auditLog).all()).toHaveLength(3); + }); +}); + +// --------------------------------------------------------------------------- +// getUserAuditLog +// --------------------------------------------------------------------------- +describe('getUserAuditLog', () => { + test('returns empty array when user has no log entries', async () => { + expect(await auditManager.getUserAuditLog(USER_A)).toEqual([]); + }); + + test('returns only entries for the specified user', async () => { + await auditManager.logAction(USER_A, 'ACTION_A'); + await auditManager.logAction(USER_B, 'ACTION_B'); + const log = await auditManager.getUserAuditLog(USER_A); + expect(log).toHaveLength(1); + expect(log[0]!.action).toBe('ACTION_A'); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, `ACTION_${i}`); + } + const log = await auditManager.getUserAuditLog(USER_A, 3); + expect(log).toHaveLength(3); + }); + + test('returns both entries when two actions are logged', async () => { + await auditManager.logAction(USER_A, 'FIRST'); + await auditManager.logAction(USER_A, 'SECOND'); + const log = await auditManager.getUserAuditLog(USER_A); + expect(log).toHaveLength(2); + const actions = log.map((e) => e.action); + expect(actions).toContain('FIRST'); + expect(actions).toContain('SECOND'); + }); +}); + +// --------------------------------------------------------------------------- +// getAllAuditLog +// --------------------------------------------------------------------------- +describe('getAllAuditLog', () => { + test('returns empty array when no entries exist', async () => { + expect(await auditManager.getAllAuditLog()).toEqual([]); + }); + + test('returns entries from all users', async () => { + await auditManager.logAction(USER_A, 'ACTION_A'); + await auditManager.logAction(USER_B, 'ACTION_B'); + await auditManager.logAction(null, 'SYSTEM'); + const log = await auditManager.getAllAuditLog(); + expect(log).toHaveLength(3); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, `ACTION_${i}`); + } + const log = await auditManager.getAllAuditLog(2); + expect(log).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// getAuditLogSince +// --------------------------------------------------------------------------- +describe('getAuditLogSince', () => { + test('returns empty array when no entries exist', async () => { + expect(await auditManager.getAuditLogSince('2000-01-01 00:00:00')).toEqual([]); + }); + + test('returns entries at or after the given timestamp', async () => { + await auditManager.logAction(USER_A, 'RECENT_ACTION'); + const log = await auditManager.getAuditLogSince('2000-01-01 00:00:00'); + expect(log).toHaveLength(1); + expect(log[0]!.action).toBe('RECENT_ACTION'); + }); + + test('excludes entries before the given timestamp', async () => { + await auditManager.logAction(USER_A, 'OLD_ACTION'); + // A future timestamp means nothing should be returned + const log = await auditManager.getAuditLogSince('2099-01-01 00:00:00'); + expect(log).toHaveLength(0); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, `ACTION_${i}`); + } + const log = await auditManager.getAuditLogSince('2000-01-01 00:00:00', 2); + expect(log).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// getAuditLogByAction +// --------------------------------------------------------------------------- +describe('getAuditLogByAction', () => { + test('returns empty array when no matching entries exist', async () => { + await auditManager.logAction(USER_A, 'OTHER_ACTION'); + expect(await auditManager.getAuditLogByAction('REDEEM')).toEqual([]); + }); + + test('returns only entries with the specified action', async () => { + await auditManager.logAction(USER_A, 'REDEEM'); + await auditManager.logAction(USER_B, 'REDEEM'); + await auditManager.logAction(USER_A, 'VIEWED_CODES'); + const log = await auditManager.getAuditLogByAction('REDEEM'); + expect(log).toHaveLength(2); + expect(log.every((e) => e.action === 'REDEEM')).toBe(true); + }); + + test('respects the limit parameter', async () => { + for (let i = 0; i < 5; i++) { + await auditManager.logAction(USER_A, 'REDEEM'); + } + const log = await auditManager.getAuditLogByAction('REDEEM', 3); + expect(log).toHaveLength(3); + }); +}); + +// --------------------------------------------------------------------------- +// deleteUserAuditLog +// --------------------------------------------------------------------------- +describe('deleteUserAuditLog', () => { + test('is a no-op when the user has no log entries', async () => { + await auditManager.deleteUserAuditLog(USER_A); + expect(db.select().from(auditLog).all()).toHaveLength(0); + }); + + test('removes all audit log entries for the specified user', async () => { + await auditManager.logAction(USER_A, 'ACTION_1'); + await auditManager.logAction(USER_A, 'ACTION_2'); + await auditManager.deleteUserAuditLog(USER_A); + expect(db.select().from(auditLog).all()).toHaveLength(0); + }); + + test('only removes entries belonging to the specified user', async () => { + await auditManager.logAction(USER_A, 'ACTION_A'); + await auditManager.logAction(USER_B, 'ACTION_B'); + await auditManager.deleteUserAuditLog(USER_A); + const remaining = db.select().from(auditLog).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.discordId).toBe(USER_B); + }); + + test('does not delete system-level (null discordId) entries', async () => { + await auditManager.logAction(null, 'SYSTEM_ACTION'); + await auditManager.logAction(USER_A, 'USER_ACTION'); + await auditManager.deleteUserAuditLog(USER_A); + const remaining = db.select().from(auditLog).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.discordId).toBeNull(); + }); + + test('after deletion getUserAuditLog returns empty for that user', async () => { + await auditManager.logAction(USER_A, 'ACTION_1'); + await auditManager.deleteUserAuditLog(USER_A); + expect(await auditManager.getUserAuditLog(USER_A)).toEqual([]); + }); +}); diff --git a/src/bot/database/auditManager.ts b/src/bot/database/auditManager.ts index b6654bf..e5ef6c6 100644 --- a/src/bot/database/auditManager.ts +++ b/src/bot/database/auditManager.ts @@ -48,6 +48,10 @@ class AuditManager { .limit(limit) .all(); } + + async deleteUserAuditLog(discordId: string): Promise { + db.delete(auditLog).where(eq(auditLog.discordId, discordId)).run(); + } } export const auditManager = new AuditManager(); diff --git a/src/bot/database/backfillManager.ts b/src/bot/database/backfillManager.ts index 7820e0a..4c72183 100644 --- a/src/bot/database/backfillManager.ts +++ b/src/bot/database/backfillManager.ts @@ -98,6 +98,46 @@ class BackfillManager { .where(eq(backfillOperations.id, operationId)) .get() as BackfillOperation | undefined; } + + async hasUserBackfillOperations(discordId: string): Promise { + const row = db + .select({ id: backfillOperations.id }) + .from(backfillOperations) + .where(eq(backfillOperations.initiatedBy, discordId)) + .limit(1) + .get(); + return row !== undefined; + } + + async hasUserActiveBackfill(discordId: string): Promise { + const row = db + .select({ id: backfillOperations.id }) + .from(backfillOperations) + .where( + and( + eq(backfillOperations.initiatedBy, discordId), + eq(backfillOperations.status, 'in_progress') + ) + ) + .limit(1) + .get(); + return row !== undefined; + } + + async deleteUserBackfillOperations(discordId: string): Promise { + const rows = db + .select({ id: backfillOperations.id }) + .from(backfillOperations) + .where(eq(backfillOperations.initiatedBy, discordId)) + .all(); + const total = rows.length; + if (total > 0) { + db.delete(backfillOperations) + .where(eq(backfillOperations.initiatedBy, discordId)) + .run(); + } + return total; + } } export const backfillManager = new BackfillManager(); diff --git a/src/bot/database/codeManager.test.ts b/src/bot/database/codeManager.test.ts index 5aad76b..c266a60 100644 --- a/src/bot/database/codeManager.test.ts +++ b/src/bot/database/codeManager.test.ts @@ -68,23 +68,23 @@ describe('addRedeemedCode', () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Success'); const rows = db.select().from(redeemedCodes).all(); expect(rows).toHaveLength(1); - expect(rows[0].code).toBe('CODE1234ABCD'); - expect(rows[0].status).toBe('Success'); - expect(rows[0].discordId).toBe(USER_A); - expect(rows[0].isPublic).toBe(0); + expect(rows[0]!.code).toBe('CODE1234ABCD'); + expect(rows[0]!.status).toBe('Success'); + expect(rows[0]!.discordId).toBe(USER_A); + expect(rows[0]!.isPublic).toBe(0); }); test('normalizes numeric status 0 to Success', async () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 0); const rows = db.select().from(redeemedCodes).all(); - expect(rows[0].status).toBe('Success'); + expect(rows[0]!.status).toBe('Success'); expect(await codeManager.isCodeRedeemedByUser('CODE1234ABCD', USER_A)).toBe(true); }); test('normalizes numeric string status "0" to Success', async () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, '0'); const rows = db.select().from(redeemedCodes).all(); - expect(rows[0].status).toBe('Success'); + expect(rows[0]!.status).toBe('Success'); expect(await codeManager.isCodeRedeemedByUser('CODE1234ABCD', USER_A)).toBe(true); }); @@ -100,7 +100,7 @@ describe('addRedeemedCode', () => { await codeManager.addRedeemedCode('CODE1234ABCD', USER_A, 'Code Expired'); const rows = db.select().from(redeemedCodes).all(); expect(rows).toHaveLength(1); - expect(rows[0].status).toBe('Code Expired'); + expect(rows[0]!.status).toBe('Code Expired'); }); test('propagates isPublic=true to all existing rows for the code', async () => { @@ -328,3 +328,42 @@ describe('getRedeemedCodeDetails', () => { expect(details.every((r) => r.discordId === USER_A)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// deleteUserRedeemedCodes +// --------------------------------------------------------------------------- +describe('deleteUserRedeemedCodes', () => { + test('returns 0 and does nothing when user has no codes', async () => { + const count = await codeManager.deleteUserRedeemedCodes(USER_A); + expect(count).toBe(0); + expect(db.select().from(redeemedCodes).all()).toHaveLength(0); + }); + + test('deletes all redeemed code rows for the user and returns the count', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_A, 'Code Expired'); + const count = await codeManager.deleteUserRedeemedCodes(USER_A); + expect(count).toBe(2); + const remaining = db.select().from(redeemedCodes).all(); + expect(remaining).toHaveLength(0); + }); + + test('only deletes rows belonging to the specified user', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE2222BBBB', USER_B, 'Success'); + const count = await codeManager.deleteUserRedeemedCodes(USER_A); + expect(count).toBe(1); + const remaining = db.select().from(redeemedCodes).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.discordId).toBe(USER_B); + }); + + test('does not affect the other user\'s records when both have redeemed the same code', async () => { + await codeManager.addRedeemedCode('CODE1111AAAA', USER_A, 'Success'); + await codeManager.addRedeemedCode('CODE1111AAAA', USER_B, 'Success'); + await codeManager.deleteUserRedeemedCodes(USER_A); + const remaining = db.select().from(redeemedCodes).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.discordId).toBe(USER_B); + }); +}); diff --git a/src/bot/database/codeManager.ts b/src/bot/database/codeManager.ts index faa5168..18bd21c 100644 --- a/src/bot/database/codeManager.ts +++ b/src/bot/database/codeManager.ts @@ -233,6 +233,16 @@ class CodeManager { db.delete(pendingCodes).run(); } } + + async deleteUserRedeemedCodes(discordId: string): Promise { + const before = db + .select({ count: sql`COUNT(*)` }) + .from(redeemedCodes) + .where(eq(redeemedCodes.discordId, discordId)) + .get(); + db.delete(redeemedCodes).where(eq(redeemedCodes.discordId, discordId)).run(); + return before?.count ?? 0; + } } export const codeManager = new CodeManager(); diff --git a/src/bot/database/userManager.test.ts b/src/bot/database/userManager.test.ts index 5f7018c..f39e9f3 100644 --- a/src/bot/database/userManager.test.ts +++ b/src/bot/database/userManager.test.ts @@ -25,10 +25,10 @@ describe('saveCredentials', () => { await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }); const rows = db.select().from(users).all(); expect(rows).toHaveLength(1); - expect(rows[0].discordId).toBe('user-1'); + expect(rows[0]!.discordId).toBe('user-1'); // Credentials are encrypted at rest — raw DB values must not be plaintext - expect(rows[0].userId).not.toBe('111'); - expect(rows[0].userHash).not.toBe('hash-a'); + expect(rows[0]!.userId).not.toBe('111'); + expect(rows[0]!.userHash).not.toBe('hash-a'); // Decrypted values match originals const creds = await userManager.getCredentials('user-1'); expect(creds?.userId).toBe('111'); @@ -54,7 +54,7 @@ describe('saveCredentials', () => { server: 'server1', }); const rows = db.select().from(users).all(); - expect(rows[0].server).toBe('server1'); + expect(rows[0]!.server).toBe('server1'); }); }); diff --git a/src/bot/handlers/autoRedeemer.test.ts b/src/bot/handlers/autoRedeemer.test.ts index 64f4bea..b327418 100644 --- a/src/bot/handlers/autoRedeemer.test.ts +++ b/src/bot/handlers/autoRedeemer.test.ts @@ -49,10 +49,12 @@ let setTimeoutSpy: ReturnType; beforeAll(() => { initializeDatabase(); // Make randomDelay a no-op so tests don't wait 2–5 s per transition. - setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation((fn: TimerHandler) => { - if (typeof fn === 'function') fn(); - return 0 as unknown as ReturnType; - }); + setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation( + ((fn: (...args: unknown[]) => void) => { + if (typeof fn === 'function') fn(); + return 0 as unknown as ReturnType; + }) as unknown as typeof setTimeout + ); }); beforeEach(() => { @@ -312,7 +314,7 @@ describe('autoRedeemForAllUsers – OutdatedInstanceId retry', () => { expect(getUserDetailsSpy).toHaveBeenCalledTimes(2); expect(submitCodeSpy).toHaveBeenCalledTimes(2); // Second submitCode call must use the fresh instance_id - expect(submitCodeSpy.mock.calls[1][0]).toMatchObject({ instanceId: freshInstanceId }); + expect(submitCodeSpy.mock.calls[1]![0]).toMatchObject({ instanceId: freshInstanceId }); expect(await codeManager.isCodeRedeemedByUser(CODE, USER_A)).toBe(true); }); diff --git a/src/bot/handlers/autoRedeemer.ts b/src/bot/handlers/autoRedeemer.ts index 8b228d3..bbeea34 100644 --- a/src/bot/handlers/autoRedeemer.ts +++ b/src/bot/handlers/autoRedeemer.ts @@ -29,7 +29,7 @@ let redeemQueue: Promise = Promise.resolve(); export function enqueueAutoRedeem(codes: string[]): void { redeemQueue = redeemQueue .then(() => autoRedeemForAllUsers(codes)) - .catch((error) => logger.error('[AUTO REDEEMER] Unhandled error during auto-redeem:', error)); + .catch((error) => { logger.error('[AUTO REDEEMER] Unhandled error during auto-redeem:', error); }); } function randomDelay(): Promise { @@ -87,7 +87,7 @@ async function redeemCodeForUser(code: string, credentials: UserCredentials): Pr logger.error(`[AUTO REDEEMER] Server switch failed for user ${discordId}`); return; } - server = newServer; + server = newServer as string; await userManager.updateServer(discordId, server); userResult = await IdleChampionsApi.getUserDetails({ server, @@ -124,7 +124,7 @@ async function redeemCodeForUser(code: string, credentials: UserCredentials): Pr if (generic.status === 4 && generic.newServer) { // Server switched mid-session - server = generic.newServer; + server = generic.newServer as string; await userManager.updateServer(discordId, server); logger.info(`[AUTO REDEEMER] Server switched for user ${discordId}, retrying submitCode`); submitResponse = await IdleChampionsApi.submitCode({ @@ -250,7 +250,7 @@ export async function autoRedeemForAllUsers(codes: string[]): Promise { ); for (let i = 0; i < allUsers.length; i++) { - const user = allUsers[i]; + const user = allUsers[i]!; logger.info(`[AUTO REDEEMER] Processing user ${user.discordId}`); for (const code of codes) { diff --git a/src/bot/utils/crypto.ts b/src/bot/utils/crypto.ts index 943c09c..26ce5ef 100644 --- a/src/bot/utils/crypto.ts +++ b/src/bot/utils/crypto.ts @@ -61,7 +61,7 @@ export function decrypt(ciphertext: string): string { if (parts.length !== 3) { throw new Error('Invalid encrypted value format'); } - const [ivHex, authTagHex, encryptedHex] = parts; + const [ivHex, authTagHex, encryptedHex] = parts as [string, string, string]; const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); const encrypted = Buffer.from(encryptedHex, 'hex');