summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-23 10:08:44 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-23 10:08:44 +0200
commite06248ef69b33d102a57aaa31fffcde3240dad9c (patch)
treed2b82eb5a5e2918336f89e241efdeed8bed5ca95
parent79b27457125dc2e259783359085021eea1d26e3a (diff)
downloadbokf-87be23e8c290019772ac206f965902a3b4fa35ce.tar.gz
bokf-87be23e8c290019772ac206f965902a3b4fa35ce.zip
user.set_password: change your own password; TUI "Byt lösenord"v0.1.68
Needs the current password (wrong ones rate limited like logins) and a password session, requires at least 10 characters, closes the user's other sessions and is audited without secrets. The TUI main menu gets "Byt lösenord" with masked prompts; ^R keeps working with the new password. Masked prompt buffers are wiped before they are freed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--clients/screens_dashboard.c66
-rw-r--r--clients/tui.c2
-rw-r--r--docs/PROTOCOL.md15
-rw-r--r--docs/STATE.md7
-rw-r--r--docs/TUI-GUIDELINES.md7
-rwxr-xr-xscripts/tui-golden.py46
-rw-r--r--src/cmd_auth.c73
-rw-r--r--src/sessions.c17
-rw-r--r--src/sessions.h2
-rw-r--r--tests/test_core.c96
10 files changed, 325 insertions, 6 deletions
diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c
index 127d4df..45f0aed 100644
--- a/clients/screens_dashboard.c
+++ b/clients/screens_dashboard.c
@@ -275,9 +275,72 @@ static const char *const MAIN_ITEMS[] = {
"Bolaget", "System",
"Ingående balans", "Räkenskapsår",
"Byt bolag",
+ "Byt lösenord",
"Logga ut / avsluta",
};
+#define PASSWORD_MIN_LEN 10 /* user.set_password's server-side minimum */
+
+/* Changes the logged-in user's password (user.set_password). Every prompt
+ is masked; Esc anywhere cancels without a request. */
+static void change_password(struct app *a)
+{
+ char cur[128] = "", pw[128] = "", again[128] = "";
+ if (!tui_prompt_into(cur, sizeof cur, "Nuvarande lösenord: ", "", 1) ||
+ !tui_prompt_into(pw, sizeof pw, "Nytt lösenord (minst 10 tecken): ",
+ "", 1) ||
+ !tui_prompt_into(again, sizeof again, "Upprepa nytt lösenord: ", "",
+ 1))
+ goto done;
+ if (strlen(pw) < PASSWORD_MIN_LEN) {
+ tui_message("Byt lösenord",
+ "Det nya lösenordet måste ha minst %d tecken.",
+ PASSWORD_MIN_LEN);
+ goto done;
+ }
+ if (strcmp(pw, again) != 0) {
+ tui_message("Byt lösenord", "Lösenorden stämmer inte överens.");
+ goto done;
+ }
+ if (strcmp(pw, cur) == 0) {
+ tui_message("Byt lösenord",
+ "Det nya lösenordet måste skilja sig från det nuvarande.");
+ goto done;
+ }
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "current_password", cur);
+ yyjson_mut_obj_add_strcpy(d, o, "new_password", pw);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp = client_rpc(&a->conn, "user.set_password", a->session, 0,
+ args);
+ if (args) {
+ memset(args, 0, strlen(args));
+ free(args);
+ }
+ if (resp && client_ok(resp)) {
+ int64_t closed = jint_val(resp, "result.sessions_closed", 0);
+ /* ^R re-logs in with the kept password */
+ if (a->password[0])
+ snprintf(a->password, sizeof a->password, "%s", pw);
+ tui_message("Byt lösenord",
+ "Lösenordet är bytt.%s\n"
+ "Loggar du in via bokftui-bw: uppdatera lösenordet i "
+ "Bitwarden.",
+ closed > 0 ? " Dina andra inloggningar har loggats ut."
+ : "");
+ } else {
+ show_error("Byt lösenord", resp);
+ }
+ free(resp);
+done:
+ memset(cur, 0, sizeof cur);
+ memset(pw, 0, sizeof pw);
+ memset(again, 0, sizeof again);
+}
+
/* Picks another org and reloads everything that belongs to the org: its
context (name, role, current fiscal year, series) and the remembered
list selections. */
@@ -356,6 +419,9 @@ void dashboard(struct app *a)
switch_org(a);
break;
case 13:
+ change_password(a);
+ break;
+ case 14:
return;
default:
break;
diff --git a/clients/tui.c b/clients/tui.c
index 4bc1d66..43a3d27 100644
--- a/clients/tui.c
+++ b/clients/tui.c
@@ -596,6 +596,8 @@ static int field_scratch(int y, int x, int width, char *buf, size_t cap,
int rc = field_loop(y, x, width, scratch, cap, mask, date, tabs, 1, first);
if (rc)
snprintf(buf, cap, "%s", scratch);
+ if (mask)
+ memset(scratch, 0, cap); /* no password copy left in freed memory */
free(scratch);
return rc;
}
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index ae1098f..9ab9620 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -86,6 +86,13 @@ and returns an opaque, high-entropy session id:
- Sliding TTL, `session_ttl` default 8 h. `session.close` ends one explicitly.
- Passwords are stored as Argon2id hashes. Failed logins are rate limited per
peer (default: 5 failures per 15 minutes, then `RATE_LIMITED`).
+- A user changes their own password with `user.set_password` from a
+ password session (a token session gets `FORBIDDEN`). The current password
+ is required — 5 wrong ones per 15 minutes give `RATE_LIMITED` — and the
+ new one must have at least 10 characters and differ from it. On success
+ every other session of the user is closed (`sessions_closed`); the call's
+ own session stays. The audit entry (`user.set_password`, also for a wrong
+ current password) carries no password.
- Token lookups compare SHA-256 hashes in constant time. Token values are
shown exactly once at creation and are never logged.
@@ -97,7 +104,7 @@ and returns an opaque, high-entropy session id:
- Tokens are the intended mechanism for agents and for accountant/viewer
access. They can be revoked immediately (`token.revoke`). Scopes are
enforced for every command, including admin commands: `backup.snapshot`
- and `user.*` need a token with the `admin` scope.
+ and `user.create`/`user.list` need a token with the `admin` scope.
### 4.3 Roles and permissions
@@ -115,6 +122,7 @@ Scopes on a token can narrow but never widen the user's role.
| `payroll.agi` (decrypted personnummer), `payroll.tax_tables_fetch/import` | | | ● | |
| `org.member_*`, `token.create` for others | | | ● | |
| `user.create`, any org | | | | ● |
+| `user.set_password` (own password) | ● | ● | ● | ● |
| `backup.snapshot` | | | ● | ● |
Any authenticated user may create a new org (config `allow_org_create`,
@@ -295,6 +303,7 @@ Arguments are shown abbreviated; `describe` is authoritative.
| `board.add` / `board.update` / `board.remove` | `name`,`title?` / `id`,`name?`,`title?` / `id` | owner; audited |
| `user.create` | `username`, `password`, `display_name`, `is_admin?` | `user` (system admin) |
| `user.list` | — | `items[]` (system admin) |
+| `user.set_password` | `current_password`, `new_password` | `sessions_closed` (own password; password session only) |
| `token.create` | `label`, `scopes[]`, `org`, `expires_at?` | `token` (shown once), `id` |
| `token.list` / `token.revoke` | — / `id` | `items[]` / `{}` |
@@ -826,6 +835,7 @@ Args: `name:type(values)[!][=default]`, `!` = required.
| `session.whoami` | viewer | no | no | no | — |
| `session.list_orgs` | viewer | no | no | no | — |
| `session.use_org` | viewer | no | no | no | `org:int!` |
+| `user.set_password` | viewer | no | yes | no | `current_password:string!`, `new_password:string!` |
| `org.create` | viewer | no | yes | yes | `name:string!`, `org_nr:string`, `fiscal_year_start_month:int=1`, `moms_period:enum(month\|quarter\|year)=month`, `framework:enum(K2\|K3)=K2` |
| `org.list` | viewer | no | no | no | — |
| `org.get` | viewer | yes | no | no | — |
@@ -935,7 +945,8 @@ Args: `name:type(values)[!][=default]`, `!` = required.
commands. Implemented screens (0.1.0-dev):
- **Inloggning** — server, user, password; org picker when several exist.
- **Byt bolag** in the main menu reopens the picker during the session.
+ **Byt bolag** in the main menu reopens the picker during the session;
+ **Byt lösenord** calls `user.set_password`.
- **Dashboard** — status line with org, fiscal year, role and user.
- **Verifikat** — list and detail view (rows with column headers, an
underlag section separated by a rule, hash, link to corrected voucher); `c`
diff --git a/docs/STATE.md b/docs/STATE.md
index 31d6b14..128e824 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -14,6 +14,13 @@ unit tests and the docs consistency check.
## Resume here (2026-09-22)
+- **Byt lösenord (2026-09-23, branch `eff/set-password`, needs a server
+ deploy)**: new command `user.set_password` (own password, password
+ session only, current password required and rate limited like logins,
+ ≥ 10 characters, other sessions closed, audited without secrets) and a
+ main-menu item in the TUI. Until the server is deployed the TUI item
+ answers `UNKNOWN_COMMAND`. Follow-up to consider: an admin reset of
+ another user's password (today only `user.create` sets one).
- **Byt bolag (2026-09-23, branch `eff/switch-org`)**: a main-menu item
reopens the org picker and switches org in the running session (context
reloaded, list selections cleared). `app_refresh_context` now clears the
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
index 68316e3..28b7f89 100644
--- a/docs/TUI-GUIDELINES.md
+++ b/docs/TUI-GUIDELINES.md
@@ -130,7 +130,12 @@ opens the same picker, with the cursor on the current org, and switches the
session to the chosen one: `session.use_org`, then the org's name, role,
current fiscal year and series are reloaded and the remembered list
selections are cleared; `Esc` keeps the current org. `Ctrl+R` keeps the
-switched org. The fiscal year is chosen from the dashboard and is changeable
+switched org. "Byt lösenord" asks for the current password, the new one
+and the new one again in masked prompts (`Esc` in any of them cancels),
+checks length (≥ 10), match and difference before calling
+`user.set_password`, and reports that the user's other logins were logged
+out and that a Bitwarden item has to be updated; `Ctrl+R` keeps working
+with the new password. The fiscal year is chosen from the dashboard and is changeable
during the session; the `Räkenskapsår` screen also closes and reopens years
there.
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
index 0dfde5f..b6aaada 100755
--- a/scripts/tui-golden.py
+++ b/scripts/tui-golden.py
@@ -689,6 +689,42 @@ SCENARIOS = [
},
],
},
+ {
+ # Keep last: changes the rig's login password, then restores it so
+ # a rerun of the list still logs in.
+ "name": "change-password",
+ "screen": "dashboard",
+ "expect": ["Byt lösenord", "Logga ut / avsluta"],
+ "steps": [
+ {
+ "keys": ["g", "lösen", "enter", "enter"],
+ "expect": ["Nuvarande lösenord:"],
+ },
+ {
+ "keys": ["{password}", "enter", "kort", "enter", "kort",
+ "enter"],
+ "expect": ["minst 10 tecken"],
+ },
+ {
+ "keys": ["enter", "g", "lösen", "enter", "enter",
+ "fel-lösenord", "enter", "nyttlosenord99", "enter",
+ "nyttlosenord99", "enter"],
+ "expect": ["AUTH_FAILED"],
+ },
+ {
+ "keys": ["enter", "g", "lösen", "enter", "enter",
+ "{password}", "enter", "nyttlosenord99", "enter",
+ "nyttlosenord99", "enter"],
+ "expect": ["Lösenordet är bytt."],
+ },
+ {
+ "keys": ["enter", "g", "lösen", "enter", "enter",
+ "nyttlosenord99", "enter", "{password}", "enter",
+ "{password}", "enter"],
+ "expect": ["Lösenordet är bytt."],
+ },
+ ],
+ },
]
# --------------------------------------------------------------------------
@@ -1098,10 +1134,13 @@ def format_expect(strings, ctx):
return [s.format(**ctx) for s in strings]
-def resolve_keys(spec):
+def resolve_keys(spec, ctx=None):
if isinstance(spec, str):
spec = [spec]
- return "".join(KEYS.get(k, k) for k in spec).encode("utf-8")
+ text = "".join(KEYS.get(k, k) for k in spec)
+ if ctx and "{password}" in text: # the rig's random login password
+ text = text.replace("{password}", ctx["password"])
+ return text.encode("utf-8")
def report_failure(name, missing, before, current):
@@ -1285,6 +1324,7 @@ def main(argv):
"fy_label": fy.get("label", ""),
"fy_start": fy.get("start_date", ""),
"fy_end": fy.get("end_date", ""),
+ "password": password,
}
print(f"bokf TUI golden tests: org {org_id} \"{ORG_NAME}\", "
f"fy {fy.get('label')} (id {fy.get('id')}), socket {sock}")
@@ -1323,7 +1363,7 @@ def main(argv):
continue
current = before
for step in sc.get("steps", []):
- keys = resolve_keys(step["keys"])
+ keys = resolve_keys(step["keys"], ctx)
want = format_expect(step.get("expect", []), ctx)
for attempt in range(1, KEY_ATTEMPTS + 1):
before = app.render()
diff --git a/src/cmd_auth.c b/src/cmd_auth.c
index 9eb628f..86dab12 100644
--- a/src/cmd_auth.c
+++ b/src/cmd_auth.c
@@ -414,6 +414,77 @@ static const struct cmd_arg args_session_use_org[] = {
{ "org", ARG_INT, 1, NULL, NULL, "Org id" },
};
+#define PASSWORD_MIN_LEN 10
+
+/* The logged-in user changes their own password. Needs the current one
+ (wrong guesses count like failed logins) and a password session: a token
+ must not be able to take over the account. */
+static yyjson_mut_val *h_user_set_password(struct req *r)
+{
+ if (r->sess->token_id)
+ return fail(r, "FORBIDDEN",
+ "log in with your password to change it (not a token)");
+ const char *cur = arg_str(r->args, "current_password");
+ const char *pw = arg_str(r->args, "new_password");
+ if (!cur || !pw)
+ return fail(r, "INVALID_ARGS",
+ "current_password and new_password are required");
+ if (strlen(pw) < PASSWORD_MIN_LEN)
+ return failf(r, "INVALID_ARGS",
+ "new password must be at least %d characters",
+ PASSWORD_MIN_LEN);
+ if (strcmp(cur, pw) == 0)
+ return fail(r, "INVALID_ARGS",
+ "new password must differ from the current one");
+ char key[64];
+ snprintf(key, sizeof key, "pw:%lld", (long long)r->sess->user_id);
+ int64_t retry = 0;
+ if (rl_blocked(key, &retry))
+ return failf(r, "RATE_LIMITED",
+ "too many wrong passwords; retry in %lld s",
+ (long long)retry);
+
+ sqlite3_stmt *st = db_prepare_bound(
+ r->db, "SELECT pw_hash FROM users WHERE id=?1", "i",
+ r->sess->user_id);
+ if (!st)
+ return db_error(r);
+ char *hash = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW && sqlite3_column_text(st, 0))
+ hash = xstrdup((const char *)sqlite3_column_text(st, 0));
+ sqlite3_finalize(st);
+ int ok = hash && auth_verify_password(hash, cur) == 0;
+ free(hash);
+ if (!ok) {
+ rl_fail(key);
+ audit_append(r->db, 0, r->sess->user_id, 0, "user.set_password",
+ "{}", "AUTH_FAILED", NULL);
+ return fail(r, "AUTH_FAILED", "current password is wrong");
+ }
+ rl_ok(key);
+
+ char *phc = NULL;
+ if (auth_hash_password(pw, &phc) != 0)
+ return fail(r, "INTERNAL", "password hashing failed");
+ int rc = req_exec(r, "UPDATE users SET pw_hash=?1 WHERE id=?2", "si", phc,
+ r->sess->user_id);
+ free(phc);
+ if (rc != 0)
+ return NULL;
+ int closed = sessions_destroy_user(r->sess->user_id, r->sess->id);
+ audit_append(r->db, 0, r->sess->user_id, 0, "user.set_password", "{}",
+ "OK", NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "sessions_closed", closed);
+ return o;
+}
+
+static const struct cmd_arg args_user_set_password[] = {
+ { "current_password", ARG_STR, 1, NULL, NULL, "Current password" },
+ { "new_password", ARG_STR, 1, NULL, NULL,
+ "New password, at least 10 characters" },
+};
+
const struct command g_cmd_auth[] = {
{ "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 },
{ "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL,
@@ -428,6 +499,8 @@ const struct command g_cmd_auth[] = {
0, 0, 0, h_session_list_orgs, NULL, 0 },
{ "session.use_org", "Switch active org", PERM_READ, 0, 0, 0,
h_session_use_org, CMD_ARGS(args_session_use_org) },
+ { "user.set_password", "Change your own password", PERM_READ, 0, 1, 0,
+ h_user_set_password, CMD_ARGS(args_user_set_password) },
};
const struct cmd_table g_cmd_table_auth = {
diff --git a/src/sessions.c b/src/sessions.c
index 66ec375..21fc1ae 100644
--- a/src/sessions.c
+++ b/src/sessions.c
@@ -74,6 +74,23 @@ void sessions_destroy(const char *id)
}
}
+int sessions_destroy_user(int64_t user_id, const char *keep_id)
+{
+ int n = 0;
+ struct session **pp = &g_sessions;
+ while (*pp) {
+ struct session *s = *pp;
+ if (s->user_id == user_id && (!keep_id || strcmp(s->id, keep_id))) {
+ *pp = s->next;
+ free(s);
+ n++;
+ continue;
+ }
+ pp = &s->next;
+ }
+ return n;
+}
+
void sessions_free_all(void)
{
struct session *s = g_sessions;
diff --git a/src/sessions.h b/src/sessions.h
index d573b23..9c084e0 100644
--- a/src/sessions.h
+++ b/src/sessions.h
@@ -23,6 +23,8 @@ struct session *sessions_create(int64_t user_id, int is_admin,
const char *scopes);
struct session *sessions_get(const char *id);
void sessions_destroy(const char *id);
+/* Ends every session of user_id except keep_id; returns how many. */
+int sessions_destroy_user(int64_t user_id, const char *keep_id);
void sessions_free_all(void);
#endif
diff --git a/tests/test_core.c b/tests/test_core.c
index 071b5ce..6376517 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -1309,6 +1309,101 @@ static void test_tokens(struct tctx *t)
yyjson_doc_free(d);
}
+static void set_pw(const char *sess, const char *cur, const char *pw,
+ const char *want_code)
+{
+ yyjson_doc *d = call(reqf(
+ "{\"v\":1,\"id\":\"pw\",\"cmd\":\"user.set_password\","
+ "\"session\":\"%s\",\"args\":{\"current_password\":\"%s\","
+ "\"new_password\":\"%s\"}}",
+ sess, cur, pw));
+ if (want_code)
+ CHECK_STR(d, "error.code", want_code);
+ else
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+}
+
+static void test_set_password(struct tctx *t)
+{
+ yyjson_doc *d;
+ CHECK(login("admin", "secret123"));
+ d = call(reqf("{\"v\":1,\"id\":\"p1\",\"cmd\":\"user.create\","
+ "\"session\":\"%s\",\"args\":{\"username\":\"pwuser\","
+ "\"password\":\"oldpassword1\"}}",
+ g_session));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ /* two sessions of the same user */
+ CHECK(login("pwuser", "oldpassword1"));
+ char other[128];
+ snprintf(other, sizeof other, "%s", g_session);
+ CHECK(login("pwuser", "oldpassword1"));
+ char mine[128];
+ snprintf(mine, sizeof mine, "%s", g_session);
+
+ set_pw(mine, "wrongpassword", "newpassword22", "AUTH_FAILED");
+ set_pw(mine, "oldpassword1", "short", "INVALID_ARGS");
+ set_pw(mine, "oldpassword1", "oldpassword1", "INVALID_ARGS");
+ d = call(reqf("{\"v\":1,\"id\":\"p2\",\"cmd\":\"user.set_password\","
+ "\"session\":\"%s\",\"args\":{\"new_password\":"
+ "\"newpassword22\"}}",
+ mine));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+ /* nothing changed yet: the other session and the old password work */
+ d = call(reqf("{\"v\":1,\"id\":\"p3\",\"cmd\":\"session.whoami\","
+ "\"session\":\"%s\"}", other));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"p4\",\"cmd\":\"user.set_password\","
+ "\"session\":\"%s\",\"args\":{\"current_password\":"
+ "\"oldpassword1\",\"new_password\":\"newpassword22\"}}",
+ mine));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.sessions_closed") == 1);
+ yyjson_doc_free(d);
+ /* other sessions are closed, this one stays */
+ d = call(reqf("{\"v\":1,\"id\":\"p5\",\"cmd\":\"session.whoami\","
+ "\"session\":\"%s\"}", other));
+ CHECK_STR(d, "error.code", "SESSION_EXPIRED");
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p6\",\"cmd\":\"session.whoami\","
+ "\"session\":\"%s\"}", mine));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.user.username", "pwuser");
+ yyjson_doc_free(d);
+ CHECK(!login("pwuser", "oldpassword1"));
+ CHECK(login("pwuser", "newpassword22"));
+ snprintf(mine, sizeof mine, "%s", g_session);
+
+ /* wrong current passwords are rate limited like logins */
+ for (int i = 0; i < 5; i++)
+ set_pw(mine, "wrongpassword", "otherpassword3", "AUTH_FAILED");
+ set_pw(mine, "newpassword22", "otherpassword3", "RATE_LIMITED");
+
+ /* a token session cannot change the password */
+ CHECK(login("admin", "secret123"));
+ d = call(reqf("{\"v\":1,\"id\":\"p7\",\"cmd\":\"token.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"label\":\"pw-test\",\"scopes\":[\"read\"]}}",
+ g_session, (int)t->org_id));
+ CHECK_OK(d);
+ char tok[128];
+ snprintf(tok, sizeof tok, "%s", jstr(d, "result.token"));
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"p8\",\"cmd\":\"session.open\",\"args\":"
+ "{\"method\":\"token\",\"token\":\"%s\"}}", tok));
+ CHECK_OK(d);
+ char tsess[128];
+ snprintf(tsess, sizeof tsess, "%s", jstr(d, "result.session"));
+ yyjson_doc_free(d);
+ set_pw(tsess, "secret123", "anotherpassword4", "FORBIDDEN");
+ CHECK(login("admin", "secret123"));
+}
+
static void test_accounts(struct tctx *t)
{
yyjson_doc *d;
@@ -5801,6 +5896,7 @@ static const struct ttest TESTS[] = {
{ "session_auth", test_session_auth, "" },
{ "org_members", test_org_members, "session_auth" },
{ "tokens", test_tokens, "org_members" },
+ { "set_password", test_set_password, "org_members" },
{ "accounts", test_accounts, "org_members" },
{ "fiscal_years", test_fiscal_years, "org_members" },
{ "vouchers", test_vouchers, "org_members" },