actualbudget / actualbudget/actual
[Bug]: A category with group=null is silently dropped by getCategories() and crashes updateCategory()
- 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
Đánh giá
Issue này chưa được đánh giá.