actualbudget / actualbudget/actual

[Bug]: A category with group=null is silently dropped by getCategories() and crashes updateCategory()

Đang mở
#8,856 1 bình luận 0 reaction 0 người được giao Xem trên GitHub
Ngôn ngữ chính
TypeScript
Star
28.7k
Fork
3k
Merge trung bình
2 ngày 11 giờ
Pull request đã merge (30 ngày)
65

Mô tả

### What happened?

A category whose `group` column is `null` (not tombstoned, a real row with real data: name, `is_income`, etc.) is:

1. **Silently excluded from `api.getCategories()`** (and therefore from `GET /api/categories-get`), even though it is not hidden and not tombstoned.
2. **Crashes `api.updateCategory()`** with `TypeError: Cannot read properties of undefined (reading 'trim')` when you try to fix it by reassigning it to a valid group.

This makes such a category permanently unreachable through the public API: it cannot be listed, updated, or (per a quick read of `deleteCategory`, which uses the same category-listing helper to pre-check existence) deleted either. `createCategory` with the same name then also fails with "already exists," since category-name uniqueness IS checked against the full table, unlike the listing helpers.

I found this via `@actual-app/api` v26.9.0 directly (bypassing any UI), while investigating why two categories a user expected to see (`Holiday Bonus`, `Other`, both `is_income: true`) were missing from their budget.

**Root cause (source-confirmed, not just a hypothesis):**

`packages/loot-core/src/server/budget/app.ts`, `getCategories()`:
```ts
list = categoryGroups.flatMap(g => g.categories ?? []);
```
This builds the flat category list by iterating category **groups** and flattening each group's own `.categories` array. A category whose `group` is `null` is nobody's child, so it can never appear in `list` — structurally, not as a filter that happens to also catch it.

`updateCategory()` in the same file:
```ts
async function updateCategory(category: CategoryEntity): Promise {
try {
await db.updateCategory(
categoryModel.toDb({
...category,
name: category.name.trim(),
}),
);
```
The `packages/api` layer that calls this appears to fetch-and-merge the existing category (presumably via the same category-listing path) before calling this handler with a partial update. For an orphaned category, that lookup finds nothing, so the merged object ends up without a `name` property at all, and `category.name.trim()` throws instead of a clean "category not found" error.

**How a category likely ends up with `group: null` in the first place:** `createCategory()` explicitly guards against this (`if (!groupId) throw APIError('Creating a category: groupId is required')`), so it cannot happen at creation time through the public API. `updateCategory()` has no equivalent guard, so calling `api.updateCategory(id, { group_id: null })` (e.g. from a script, or a naive attempt to "detach"/"remove" a category from its group) silently succeeds and orphans the row.

### How can we reproduce the issue?

Using `@actual-app/api` directly (no UI needed):

1. Connect and open a budget:
```js
import * as api from '@actual-app/api';
await api.init({ dataDir, serverURL, password });
await api.downloadBudget(syncId);
```
2. Create a category group and a category inside it, e.g. `const groupId = await api.createCategoryGroup({ name: 'Test Group' }); const catId = await api.createCategory({ name: 'Orphan Test', group_id: groupId, is_income: true });`
3. Orphan it: `await api.updateCategory(catId, { group_id: null }); await api.sync();`
Expected: either this call is rejected (group is required, mirroring `createCategory`'s own guard), or the category remains fully manageable afterward.
Actual: the call succeeds silently.
4. `const cats = await api.getCategories(); console.log(cats.find(c => c.id === catId));`
Expected: the category is returned (it is not hidden, not deleted).
Actual: `undefined` — it is missing from the result entirely.
5. Try to fix it: `await api.updateCategory(catId, { group_id: groupId });`
Expected: succeeds, or a clean error like "category not found."
Actual: throws `TypeError: Cannot read properties of undefined (reading 'trim')` from inside `updateCategory$1` in the bundled `@actual-app/api` dist (`packages/loot-core/src/server/budget/app.ts`'s `updateCategory`, per source).
6. `await api.createCategory({ name: 'Orphan Test', group_id: groupId, is_income: true });`
Expected: succeeds (the old row is unreachable, so a caller trying to recover would expect this to work).
Actual: fails with `A category with the name "Orphan Test" already exists.` — the uniqueness check DOES see the orphaned row, unlike every other lookup, which is inconsistent with everything else in this report.

At this point the category is a true dead end: not listed, not updatable, not deletable, and its name is not reusable.

### Environment

- **Where are you hosting Actual?** Docker (self-hosted `actualbudget/actual-server:latest`)
- **What browsers are you seeing the problem on?** N/A — reproduced via `@actual-app/api` v26.9.0 directly, not through the web/desktop UI
- **Operating System:** Linux (server side); client was a Node.js script

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.