forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
27.7 KiB
1//! `forge admin create-user <username> [--admin]`: creates a user from the CLI.
2//! The first user on an instance is always an admin.
3//! `forge admin set-username <current> <NewCase>`: changes only the capitalization of a username.
4//! `forge admin reset-password <username>`: sets a random temporary password and signs the
5//! user out everywhere.
6//! `forge admin trash-list`: lists deleted repos in `data_dir/trash`, oldest first.
7//! `forge admin trash-purge --older-than-days N`: permanently removes trash older than N days.
8//! `forge admin audit-purge --older-than-days N`: deletes audit log entries older than N days.
9//! `forge admin backup --out DIR [--include-key] [--repos-only | --db-only]`: writes a backup
10//! set under DIR (see `docs/backup.md`). Exits non-zero on any failure.
11//! `forge admin fsck [--full]`: git fsck on every repo plus the database integrity check.
12//! `forge admin lfs-gc`: deletes LFS object files no repo links, once they are a day old.
13//! `forge admin backup-verify PATH`: checks a backup set (hashes, bundles, database). Exits
14//! non-zero if any check fails.
15//! `forge admin restore --from SET --data-dir NEW_DIR`: restores a verified backup set into a
16//! new data dir (see `docs/backup.md`).
17//! `forge admin delete-user <username> [--yes]`: deletes an account and moves its content to
18//! the ghost user. Without `--yes` it only prints what would happen and fails.
19//! Actions taken here are recorded in the audit log with actor `cli`.
20
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use forge_core::store::user_delete::{DeletionReport, GHOST_USERNAME};
25use forge_core::{AuditAction, Config, Error, Store, names, token};
26use forge_git::GitEnv;
27
28use crate::admin_client::running_server;
29use crate::audit::{self, Actor, Target};
30use crate::auth::hash_password_async;
31use crate::backup::{
32 BackupOptions, BackupSource, BackupSummary, FsckSummary, Scope, create_backup, restore_backup,
33 run_fsck, verify_backup,
34};
35use crate::boot::{MIN_GIT, find_git};
36use forge_core::store::backup_runs::{RunKind, RunTrigger};
37
38/// Length of a temporary password from `admin reset-password`.
39const TEMPORARY_PASSWORD_CHARS: usize = 20;
40
41const MIN_PASSWORD_CHARS: usize = 12;
42
43/// Creates a user. The password comes from FORGE_PASSWORD, or is prompted for twice on the tty.
44/// Refusal text for admin commands that would give a login to an organization.
45const ORG_NO_LOGIN: &str =
46 "an organization cannot sign in, so it has no password or login name here";
47/// Refusal text for `create-user` on a name that an organization already holds.
48const ORG_NAME_TAKEN: &str = "that name belongs to an organization";
49
50pub async fn create_user(
51 config_path: &Path,
52 username: &str,
53 make_admin: bool,
54) -> forge_core::Result<()> {
55 let (username, _) = names::validate_username(username)?;
56 let pw = read_password()?;
57 if pw.chars().count() < MIN_PASSWORD_CHARS {
58 return Err(Error::Invalid(format!(
59 "password must be at least {MIN_PASSWORD_CHARS} characters"
60 )));
61 }
62
63 let config = Config::load(config_path)?;
64 if let Some(parent) = config.database.parent() {
65 std::fs::create_dir_all(parent)?;
66 }
67 let store = Store::open(&config.database).await?;
68 if store
69 .user_by_username(&username)
70 .await?
71 .is_some_and(|existing| existing.is_org())
72 {
73 return Err(Error::Conflict(ORG_NAME_TAKEN.into()));
74 }
75
76 let hash = hash_password_async(pw).await?;
77 // The first user is always an admin. The insert is atomic, so a concurrent creator falls
78 // through to the ordinary path instead of racing into a second admin.
79 let user = match store.create_first_admin(&username, None, &hash).await? {
80 Some(user) => user,
81 None => {
82 store
83 .create_user(&username, None, &hash, make_admin)
84 .await?
85 }
86 };
87
88 audit::record(
89 &store,
90 None,
91 Actor::Cli,
92 AuditAction::UserCreated,
93 Target {
94 kind: "user",
95 id: &user.id,
96 name: &user.username,
97 },
98 serde_json::json!({ "admin": user.is_admin }),
99 )
100 .await;
101 println!(
102 "created user {} (id {}{})",
103 user.username,
104 user.id,
105 if user.is_admin { ", admin" } else { "" }
106 );
107 Ok(())
108}
109
110/// Deletes a user account and moves its authored content to the ghost user. Without `yes` this
111/// is a dry run: it prints what would happen, changes nothing and fails.
112pub async fn delete_user(config_path: &Path, username: &str, yes: bool) -> forge_core::Result<()> {
113 let config = Config::load(config_path)?;
114 let store = Store::open(&config.database).await?;
115 let report = delete_user_in(&store, username, yes).await?;
116 print_deletion_report(&format!("deleted {username}"), &report);
117 Ok(())
118}
119
120/// The store-level part of [`delete_user`]. Returns the report of what was (or, with `yes`
121/// unset, would be) done. Without `yes` nothing is written and an error is returned, so the
122/// command exits nonzero. The ghost account is not created by a dry run.
123pub async fn delete_user_in(
124 store: &Store,
125 username: &str,
126 yes: bool,
127) -> forge_core::Result<DeletionReport> {
128 let user = store
129 .user_by_username(username)
130 .await?
131 .ok_or(Error::NotFound)?;
132 if names::normalize_name(&user.username) == GHOST_USERNAME {
133 return Err(Error::Forbidden(
134 "the ghost account cannot be deleted".into(),
135 ));
136 }
137 let ghost_id = store.user_by_username(GHOST_USERNAME).await?.map(|g| g.id);
138 let preview = store
139 .deletion_preview(&user.id, ghost_id.as_deref())
140 .await?;
141 if !yes {
142 print_deletion_report(&format!("would delete {}", user.username), &preview);
143 return Err(Error::Invalid(format!(
144 "dry run: nothing was deleted. Run again with --yes to delete {}",
145 user.username
146 )));
147 }
148 let ghost = store.ensure_ghost_user().await?;
149 let report = store.delete_user(&user.id, &ghost.id).await?;
150 audit::record(
151 store,
152 None,
153 Actor::Cli,
154 AuditAction::UserDeleted,
155 Target {
156 kind: "user",
157 id: &user.id,
158 name: &user.username,
159 },
160 serde_json::json!({ "reassigned_to": GHOST_USERNAME }),
161 )
162 .await;
163 tracing::info!(user = %user.username, "admin deleted a user account");
164 Ok(report)
165}
166
167fn print_deletion_report(header: &str, report: &DeletionReport) {
168 println!("{header}:");
169 println!(" moved to the ghost user:");
170 for (label, n) in &report.reassigned {
171 println!(" {n} {label}");
172 }
173 println!(" removed with the account:");
174 for (label, n) in &report.removed {
175 println!(" {n} {label}");
176 }
177}
178
179/// Changes the capitalization of an existing user's username. `current` is matched
180/// case-insensitively; `new_display` must differ from it only in case.
181pub async fn set_username(
182 config_path: &Path,
183 current: &str,
184 new_display: &str,
185) -> forge_core::Result<()> {
186 let config = Config::load(config_path)?;
187 let store = Store::open(&config.database).await?;
188 let user = store
189 .user_by_username(current)
190 .await?
191 .ok_or(Error::NotFound)?;
192 if user.is_org() {
193 return Err(Error::Forbidden(ORG_NO_LOGIN.into()));
194 }
195 let display = names::capitalization_change(&user.username, new_display)?;
196 store.set_username_display(&user.id, &display).await?;
197 audit::record(
198 &store,
199 None,
200 Actor::Cli,
201 AuditAction::UsernameChanged,
202 Target {
203 kind: "user",
204 id: &user.id,
205 name: &display,
206 },
207 serde_json::json!({ "from": user.username }),
208 )
209 .await;
210 println!("renamed {} to {}", user.username, display);
211 Ok(())
212}
213
214/// Sets a random temporary password for `username`, revokes every session of that user and
215/// prints the password. Returns the password. Nothing else changes.
216pub async fn reset_password(config_path: &Path, username: &str) -> forge_core::Result<()> {
217 let config = Config::load(config_path)?;
218 let store = Store::open(&config.database).await?;
219 let (password, display) = reset_password_in(&store, username).await?;
220 println!("temporary password for {display}: {password}");
221 println!("all sessions of {display} were signed out; change this password after logging in");
222 Ok(())
223}
224
225/// The store-level part of [`reset_password`]. Returns the new password and the display name.
226pub async fn reset_password_in(
227 store: &Store,
228 username: &str,
229) -> forge_core::Result<(String, String)> {
230 let user = store
231 .user_by_username(username)
232 .await?
233 .ok_or(Error::NotFound)?;
234 if user.is_org() {
235 return Err(Error::Forbidden(ORG_NO_LOGIN.into()));
236 }
237 if names::normalize_name(&user.username) == GHOST_USERNAME {
238 return Err(Error::Forbidden("the ghost account has no password".into()));
239 }
240 let password = token::random_readable(TEMPORARY_PASSWORD_CHARS);
241 let hash = hash_password_async(password.clone()).await?;
242 store.set_password_hash(&user.id, &hash).await?;
243 store.delete_user_sessions(&user.id).await?;
244 audit::record(
245 store,
246 None,
247 Actor::Cli,
248 AuditAction::PasswordResetByAdmin,
249 Target {
250 kind: "user",
251 id: &user.id,
252 name: &user.username,
253 },
254 serde_json::Value::Null,
255 )
256 .await;
257 tracing::info!(user = %user.username, "admin reset a password and revoked sessions");
258 Ok((password, user.username))
259}
260
261/// Prints each trash entry as `<deleted_at unix seconds>\t<repo id>\t<path>`.
262pub fn trash_list(config_path: &Path) -> forge_core::Result<()> {
263 let config = Config::load(config_path)?;
264 let entries = forge_git::repo::list_trash(&config.data_dir)?;
265 if entries.is_empty() {
266 println!("trash is empty");
267 }
268 for entry in entries {
269 println!(
270 "{}\t{}\t{}",
271 entry.deleted_at,
272 entry.repo_id,
273 entry.path.display()
274 );
275 }
276 Ok(())
277}
278
279/// Permanently removes trash entries deleted at least `days` days ago.
280pub fn trash_purge(config_path: &Path, days: u64) -> forge_core::Result<()> {
281 let config = Config::load(config_path)?;
282 let now = std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .map(|d| d.as_secs())
285 .map_err(|e| Error::Invalid(format!("system clock is before 1970: {e}")))?;
286 let older_than = days.saturating_mul(86_400);
287 let removed = forge_git::repo::purge_trash(&config.data_dir, older_than, now)?;
288 for path in &removed {
289 tracing::info!(path = %path.display(), "purged trashed repository");
290 println!("purged {}", path.display());
291 }
292 println!("purged {} trashed repositories", removed.len());
293 Ok(())
294}
295
296/// Deletes audit log entries older than `days` days. The purge itself is logged to tracing, not
297/// to the audit log, so the log it shrinks does not record its own removal.
298pub async fn audit_purge(config_path: &Path, days: u64) -> forge_core::Result<()> {
299 let config = Config::load(config_path)?;
300 let store = Store::open(&config.database).await?;
301 let cutoff = forge_core::time::stamp_ago(Duration::from_secs(days.saturating_mul(86_400)));
302 let removed = store.purge_audit_before(&cutoff).await?;
303 tracing::info!(removed, cutoff = %cutoff, "purged audit log entries");
304 println!("purged {removed} audit log entries older than {days} days");
305 Ok(())
306}
307
308fn read_password() -> forge_core::Result<String> {
309 if let Ok(pw) = std::env::var("FORGE_PASSWORD") {
310 return Ok(pw);
311 }
312 let first = rpassword::prompt_password("Password: ")?;
313 let second = rpassword::prompt_password("Repeat password: ")?;
314 if first != second {
315 return Err(Error::Invalid("passwords do not match".to_string()));
316 }
317 Ok(first)
318}
319
320/// Parses the flags of `admin backup`. `--out DIR` is required. `--repos-only` and `--db-only`
321/// cannot be combined. The key is left out unless `--include-key` is given.
322pub fn parse_backup_args(args: &[&str]) -> std::result::Result<BackupOptions, String> {
323 let mut out = None;
324 let mut include_key = false;
325 let mut scope = None;
326 let mut rest = args.iter();
327 while let Some(arg) = rest.next() {
328 match *arg {
329 "--out" => {
330 let dir = rest.next().ok_or("--out needs a directory")?;
331 out = Some(PathBuf::from(dir));
332 }
333 "--include-key" => include_key = true,
334 "--repos-only" | "--db-only" => {
335 let wanted = if *arg == "--repos-only" {
336 Scope::ReposOnly
337 } else {
338 Scope::DbOnly
339 };
340 if scope.replace(wanted).is_some() {
341 return Err("--repos-only and --db-only cannot be combined".into());
342 }
343 }
344 other => return Err(format!("unknown backup option: {other}")),
345 }
346 }
347 let out = out.ok_or("backup needs --out DIR")?;
348 Ok(BackupOptions {
349 out,
350 include_key,
351 scope: scope.unwrap_or(Scope::All),
352 })
353}
354
355/// Writes a backup set (`admin backup`). If the server is running, it writes the set and records
356/// the run (trigger `cli`). Otherwise the database is opened directly, so the server must be
357/// stopped.
358pub async fn backup(config_path: &Path, opts: BackupOptions) -> forge_core::Result<()> {
359 let config = Config::load(config_path)?;
360 let out = std::path::absolute(&opts.out)?;
361 if let Some(server) = running_server(&config).await? {
362 let body = serde_json::json!({
363 "out": out,
364 "include_key": opts.include_key,
365 "scope": opts.scope,
366 });
367 let summary: BackupSummary = server.post("/internal/admin/backup", &body).await?;
368 print_backup_summary(&summary);
369 return Ok(());
370 }
371 let data_dir = config.data_dir.canonicalize()?;
372 let store = Store::open(&config.database).await?;
373 let git = GitEnv {
374 git_bin: find_git(),
375 data_dir,
376 };
377 forge_git::version::require_min_version(&git, MIN_GIT).await?;
378 let source = BackupSource {
379 store: store.clone(),
380 git,
381 base_url: config.base_url.clone(),
382 };
383 let target_name = out.to_string_lossy().into_owned();
384 audit::record(
385 &store,
386 None,
387 Actor::Cli,
388 AuditAction::BackupStarted,
389 Target {
390 kind: "backup",
391 id: "",
392 name: &target_name,
393 },
394 serde_json::json!({ "trigger": "cli" }),
395 )
396 .await;
397 let run = store.start_run(RunKind::Backup, RunTrigger::Cli).await?;
398 let outcome = create_backup(&source, &BackupOptions { out, ..opts }).await;
399 let (ok, path, bytes, error) = match &outcome {
400 Ok(summary) => (
401 true,
402 Some(summary.path.to_string_lossy().into_owned()),
403 Some(summary.bytes),
404 None,
405 ),
406 Err(e) => (false, None, None, Some(e.to_string())),
407 };
408 if let Err(e) = store
409 .finish_run(&run, ok, path.as_deref(), bytes, error.as_deref())
410 .await
411 {
412 tracing::error!(error = %e, "could not record the outcome of an offline backup");
413 }
414 let summary = outcome?;
415 tracing::info!(path = %summary.path.display(), bytes = summary.bytes, "backup set written");
416 print_backup_summary(&summary);
417 Ok(())
418}
419
420fn print_backup_summary(summary: &BackupSummary) {
421 println!("backup written: {}", summary.path.display());
422 println!(
423 " scope: {:?}, files: {} ({} bytes)",
424 summary.scope, summary.files, summary.bytes
425 );
426 println!(
427 " repos: {} (bundles: {}, empty: {})",
428 summary.repos, summary.bundles, summary.empty_repos
429 );
430 match summary.schema_version {
431 Some(v) => println!(" database: snapshot at schema version {v}"),
432 None => println!(" database: not included"),
433 }
434 println!(
435 " key: {}",
436 if summary.key_included {
437 "included (keep this set private)"
438 } else {
439 "not included (back up forge.key separately)"
440 }
441 );
442}
443
444/// Runs `git fsck` on every repo and the database integrity check (`admin fsck [--full]`). The
445/// server runs it when it is up, otherwise the database is opened directly. Fails when any check
446/// fails.
447pub async fn fsck(config_path: &Path, full: bool) -> forge_core::Result<()> {
448 let config = Config::load(config_path)?;
449 let summary: FsckSummary = match running_server(&config).await? {
450 Some(server) => {
451 server
452 .post("/internal/admin/fsck", &serde_json::json!({ "full": full }))
453 .await?
454 }
455 None => {
456 let store = Store::open(&config.database).await?;
457 let git = GitEnv {
458 git_bin: find_git(),
459 data_dir: config.data_dir.canonicalize()?,
460 };
461 audit::record(
462 &store,
463 None,
464 Actor::Cli,
465 AuditAction::FsckStarted,
466 Target {
467 kind: "fsck",
468 id: "",
469 name: "",
470 },
471 serde_json::json!({ "trigger": "cli", "full": full }),
472 )
473 .await;
474 let run = store.start_run(RunKind::Fsck, RunTrigger::Cli).await?;
475 let outcome = run_fsck(&store, &git, full).await;
476 let (ok, error) = match &outcome {
477 Ok(summary) if summary.is_ok() => (true, None),
478 Ok(summary) => (false, Some(summary.problems.join("\n"))),
479 Err(e) => (false, Some(e.to_string())),
480 };
481 if let Err(e) = store
482 .finish_run(&run, ok, None, None, error.as_deref())
483 .await
484 {
485 tracing::error!(error = %e, "could not record the outcome of an offline fsck");
486 }
487 outcome?
488 }
489 };
490 println!(
491 "fsck ({}): {} repos checked",
492 if summary.full { "full" } else { "quick" },
493 summary.repos
494 );
495 for problem in &summary.problems {
496 println!(" problem: {problem}");
497 }
498 if summary.is_ok() {
499 println!(" database: integrity check ok");
500 Ok(())
501 } else {
502 Err(Error::Invalid(format!(
503 "fsck found {} problem(s)",
504 summary.problems.len()
505 )))
506 }
507}
508
509/// Deletes unlinked LFS objects and abandoned upload temp files (`admin lfs-gc`). The server runs
510/// it when it is up, otherwise the database is opened directly.
511pub async fn lfs_gc(config_path: &Path) -> forge_core::Result<()> {
512 let config = Config::load(config_path)?;
513 let summary: crate::lfs::gc::GcSummary = match running_server(&config).await? {
514 Some(server) => {
515 server
516 .post("/internal/admin/lfs-gc", &serde_json::json!({}))
517 .await?
518 }
519 None => {
520 let store = Store::open(&config.database).await?;
521 crate::lfs::gc::gc(&store, &config.data_dir, crate::lfs::gc::GC_MIN_AGE).await?
522 }
523 };
524 println!(
525 "lfs-gc: {} object file(s) checked, {} removed ({} bytes), {} abandoned upload(s) removed",
526 summary.checked, summary.removed, summary.bytes_freed, summary.temp_removed
527 );
528 Ok(())
529}
530
531/// Verifies a backup set (`admin backup-verify`). Prints every problem found and fails if there
532/// is any. Scratch repos go next to the set, not in `/tmp`.
533pub async fn backup_verify(path: &Path) -> forge_core::Result<()> {
534 let path = std::path::absolute(path)?;
535 let parent = path
536 .parent()
537 .ok_or_else(|| Error::Invalid("the backup set has no parent directory".into()))?
538 .to_path_buf();
539 // Git runs with the set's parent as its HOME, so no user gitconfig is read.
540 let git = GitEnv {
541 git_bin: find_git(),
542 data_dir: parent,
543 };
544 forge_git::version::require_min_version(&git, MIN_GIT).await?;
545 let report = verify_backup(&path, &git).await?;
546 println!("backup set: {}", report.path.display());
547 println!(
548 " scope: {:?}, files: {} ({} bytes)",
549 report.scope, report.files, report.bytes
550 );
551 println!(
552 " repos: {} (bundles verified: {}, empty: {})",
553 report.repos, report.bundles_verified, report.empty_repos
554 );
555 if let Some(v) = report.schema_version {
556 println!(" database: schema version {v}, integrity_check ok");
557 }
558 if report.is_ok() {
559 println!("result: OK");
560 return Ok(());
561 }
562 for problem in &report.problems {
563 println!(" problem: {problem}");
564 }
565 println!("result: FAILED ({} problem(s))", report.problems.len());
566 Err(Error::Invalid(format!(
567 "backup set failed verification: {}",
568 report.path.display()
569 )))
570}
571
572/// Parses the flags of `admin restore`: `--from SET --data-dir NEW_DIR`, both required.
573pub fn parse_restore_args(args: &[&str]) -> std::result::Result<(PathBuf, PathBuf), String> {
574 let mut from = None;
575 let mut data_dir = None;
576 let mut rest = args.iter();
577 while let Some(arg) = rest.next() {
578 match *arg {
579 "--from" => {
580 let set = rest.next().ok_or("--from needs a backup set path")?;
581 from = Some(PathBuf::from(set));
582 }
583 "--data-dir" => {
584 let dir = rest.next().ok_or("--data-dir needs a directory")?;
585 data_dir = Some(PathBuf::from(dir));
586 }
587 other => return Err(format!("unknown restore option: {other}")),
588 }
589 }
590 match (from, data_dir) {
591 (Some(from), Some(dir)) => Ok((from, dir)),
592 _ => Err("restore needs --from SET and --data-dir NEW_DIR".into()),
593 }
594}
595
596/// Restores a verified backup set into a new data dir (`admin restore`). Needs no config and no
597/// running server. The restored database is written to `NEW_DIR/forge.db`, the layout a fresh
598/// install uses. Point `database` in the config at it before starting the server.
599pub async fn restore(set: &Path, new_data_dir: &Path) -> forge_core::Result<()> {
600 let git_bin = find_git();
601 let set_abs = std::path::absolute(set)?;
602 let probe = GitEnv {
603 git_bin: git_bin.clone(),
604 data_dir: set_abs.parent().unwrap_or(&set_abs).to_path_buf(),
605 };
606 forge_git::version::require_min_version(&probe, MIN_GIT).await?;
607 match restore_backup(&set_abs, new_data_dir, &git_bin).await {
608 Ok(report) => {
609 println!("restored into: {}", report.data_dir.display());
610 println!(" database: {}", report.database.display());
611 println!(
612 " repos: {} from bundles, {} empty",
613 report.repos, report.empty_repos
614 );
615 println!(
616 " key: {}",
617 if report.key_restored {
618 "restored (forge.key, mode 0600)"
619 } else {
620 "NOT restored"
621 }
622 );
623 for warning in &report.warnings {
624 println!("WARNING: {warning}");
625 }
626 println!(
627 "next: set `database = \"{}\"` and `data_dir = \"{}\"` in the config,",
628 report.database.display(),
629 report.data_dir.display()
630 );
631 println!(" then run `forge admin fsck --full` before starting the server.");
632 Ok(())
633 }
634 Err(e) => {
635 eprintln!("RESTORE FAILED: {e}");
636 if new_data_dir.exists() {
637 eprintln!(
638 "The directory {} was left as it is for inspection. Do not start the server from it.",
639 new_data_dir.display()
640 );
641 }
642 Err(e)
643 }
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::auth::verify_password_async;
651
652 #[tokio::test]
653 async fn reset_password_sets_a_20_char_password_and_revokes_sessions() {
654 let dir = tempfile::tempdir().unwrap();
655 let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
656 let hash = hash_password_async("the original password".into())
657 .await
658 .unwrap();
659 let user = store
660 .create_user("Alice", None, &hash, false)
661 .await
662 .unwrap();
663 let expiry = forge_core::time::stamp_after(std::time::Duration::from_secs(3600));
664 store
665 .create_session(&user.id, "h1", "c1", &expiry, None)
666 .await
667 .unwrap();
668 store
669 .create_session(&user.id, "h2", "c2", &expiry, None)
670 .await
671 .unwrap();
672
673 let (password, display) = reset_password_in(&store, "alice").await.unwrap();
674 assert_eq!(password.chars().count(), TEMPORARY_PASSWORD_CHARS);
675 assert_eq!(display, "Alice");
676 assert!(store.list_sessions(&user.id).await.unwrap().is_empty());
677
678 let fresh = store.user_by_id(&user.id).await.unwrap().unwrap();
679 assert!(
680 verify_password_async(password, fresh.password_hash.clone())
681 .await
682 .unwrap()
683 );
684 let old_ok = verify_password_async("the original password".into(), fresh.password_hash)
685 .await
686 .unwrap();
687 assert!(!old_ok);
688 }
689
690 #[tokio::test]
691 async fn delete_user_dry_run_changes_nothing() {
692 let dir = tempfile::tempdir().unwrap();
693 let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
694 store.create_user("Dana", None, "hash", true).await.unwrap();
695 store
696 .create_user("Alice", None, "hash", false)
697 .await
698 .unwrap();
699
700 let dry = delete_user_in(&store, "alice", false).await;
701 assert!(matches!(dry, Err(Error::Invalid(_))), "{dry:?}");
702 assert!(store.user_by_username("alice").await.unwrap().is_some());
703 assert!(
704 store
705 .user_by_username(GHOST_USERNAME)
706 .await
707 .unwrap()
708 .is_none(),
709 "a dry run must not create the ghost account"
710 );
711
712 delete_user_in(&store, "alice", true).await.unwrap();
713 assert!(store.user_by_username("alice").await.unwrap().is_none());
714 assert!(
715 store
716 .user_by_username(GHOST_USERNAME)
717 .await
718 .unwrap()
719 .is_some()
720 );
721 }
722
723 #[tokio::test]
724 async fn reset_password_for_an_unknown_user_is_not_found() {
725 let dir = tempfile::tempdir().unwrap();
726 let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
727 let err = reset_password_in(&store, "nobody").await;
728 assert!(matches!(err, Err(Error::NotFound)));
729 }
730}
731
732#[cfg(test)]
733mod org_account_tests {
734 use super::*;
735
736 /// An organization has no password to reset, and deleting its last owner is refused with the
737 /// message the admin page and the CLI show.
738 #[tokio::test]
739 async fn org_accounts_refuse_password_reset_and_last_owner_deletion() {
740 let dir = tempfile::tempdir().unwrap();
741 let store = Store::open(&dir.path().join("forge.db")).await.unwrap();
742 store.create_user("Dana", None, "hash", true).await.unwrap();
743 let alice = store
744 .create_user("Alice", None, "hash", false)
745 .await
746 .unwrap();
747 let org = store.create_org("acme", &alice.id).await.unwrap();
748
749 assert!(matches!(
750 reset_password_in(&store, "acme").await,
751 Err(Error::Forbidden(_))
752 ));
753 let untouched = store.user_by_id(&org.id).await.unwrap().unwrap();
754 assert_eq!(untouched.password_hash, "!org");
755
756 let refused = delete_user_in(&store, "alice", true).await;
757 assert!(
758 matches!(&refused, Err(Error::Conflict(msg)) if msg == forge_core::store::user_delete::LAST_ORG_OWNER),
759 "{refused:?}"
760 );
761 assert!(store.user_by_id(&alice.id).await.unwrap().is_some());
762 }
763}