From 4bd12bfb03b75715b6bd87fa89708e74139794ea Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 10:10:08 +0100 Subject: [PATCH] test: replace the Go compatibility fixtures with native round-trip tests The Go-generated link.db, profiles.link and rec1.cast fixtures and the tests that read them are removed. Their coverage is kept by tests on data this build writes: a fresh database round-trips SSH profiles (env vars, login scripts, forwards), serial profiles, IRC room state, known hosts, snippets and preferences, and encrypted exports round-trip and reject a wrong password. The import dialog no longer offers .link files and the docs stop mentioning them. --- docs/overview.md | 2 +- docs/user-manual.md | 2 +- rust/app/src/settings.rs | 2 +- rust/app/src/transfer.rs | 4 +- rust/core/src/exportimport.rs | 14 ++- rust/core/src/models.rs | 4 +- rust/core/src/recording.rs | 15 ---- rust/core/src/service.rs | 2 +- rust/core/src/storage.rs | 84 ++++++++++++------ rust/core/tests/fixtures/README.md | 11 --- rust/core/tests/fixtures/link.db | Bin 65536 -> 0 bytes rust/core/tests/fixtures/profiles.link | Bin 8222 -> 0 bytes rust/core/tests/fixtures/recordings/rec1.cast | 3 - 13 files changed, 69 insertions(+), 74 deletions(-) delete mode 100644 rust/core/tests/fixtures/README.md delete mode 100644 rust/core/tests/fixtures/link.db delete mode 100644 rust/core/tests/fixtures/profiles.link delete mode 100644 rust/core/tests/fixtures/recordings/rec1.cast diff --git a/docs/overview.md b/docs/overview.md index ff138f1..8c03291 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -41,7 +41,7 @@ Use Nexus Terminal when you need to: - Multi-select profiles with Ctrl/Cmd-click for bulk connect, disconnect, tagging, moving between groups and deleting. - Test basic host and port reachability before connecting. - Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files and jump hosts. -- Export and import profiles using encrypted `.nexus` bundles; older `.link` bundles from Alchementary Link import too. +- Export and import profiles using encrypted `.nexus` bundles. - Plain JSON export and import, with secrets redacted unless you choose to include them. - Choose import behaviour for duplicates: skip, overwrite or duplicate. diff --git a/docs/user-manual.md b/docs/user-manual.md index 654439c..f9fc15b 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -379,7 +379,7 @@ Connections, disconnections, profile changes, credential access, imports and exp ## 13. Import and export - **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included. -- **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file (or a `.link` file from Alchementary Link), enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**. +- **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file, enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**. - **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; passwords in login-script `send` steps that answer a password prompt are blanked unless you tick **Include secrets**. Proxy passwords and TOTP secrets live in the keychain or vault, never in the profile, so no export carries them. - **OpenSSH config**: **File → Import ~/.ssh/config** or **Settings → About → Import ~/.ssh/config** creates profiles from your `Host` blocks, including hostnames, usernames, ports, identity files and jump hosts. diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs index 9a98f89..d32b801 100644 --- a/rust/app/src/settings.rs +++ b/rust/app/src/settings.rs @@ -864,7 +864,7 @@ When exporting profiles, encrypted export (a `.nexus` file) protects the file wi "## Exporting Profiles Use **Export (Encrypted)** from the sidebar menu or command palette. Choose which profiles to include and set a password to encrypt the file. Exported files use the `.nexus` extension. Plain **JSON** export is also available, optionally including secrets. ## Importing Profiles -Use **Import (Encrypted)**, select a `.nexus` file (older `.link` exports from Alchementary Link work too) and enter the password used when exporting. Choose whether existing profiles are skipped, overwritten or duplicated. Imported profiles appear in your sidebar immediately. +Use **Import (Encrypted)**, select a `.nexus` file and enter the password used when exporting. Choose whether existing profiles are skipped, overwritten or duplicated. Imported profiles appear in your sidebar immediately. ## Importing from OpenSSH If you have an existing `~/.ssh/config` file, go to **Settings → About** and click **Import ~/.ssh/config**. Nexus Terminal creates profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.", ), diff --git a/rust/app/src/transfer.rs b/rust/app/src/transfer.rs index 12d89cd..7cf1fb7 100644 --- a/rust/app/src/transfer.rs +++ b/rust/app/src/transfer.rs @@ -1,4 +1,4 @@ -//! Profile export/import: encrypted `.nexus` (also reads `.link`) and plain JSON. +//! Profile export/import: encrypted `.nexus` and plain JSON. use crate::settings::{Job, poll}; use crate::state::{Request, Shared}; @@ -54,7 +54,7 @@ impl Transfer { pub fn import_encrypted(&mut self) { if let Some(path) = - rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file() + rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION]).add_filter("All files", &["*"]).pick_file() { self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() }); } diff --git a/rust/core/src/exportimport.rs b/rust/core/src/exportimport.rs index 14f79b1..8adbfd8 100644 --- a/rust/core/src/exportimport.rs +++ b/rust/core/src/exportimport.rs @@ -1,6 +1,5 @@ //! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]` -//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary -//! Link are the same format. +//! over a JSON bundle, with the vault's Argon2id parameters. use crate::Result; use crate::models::Profile; @@ -76,14 +75,13 @@ mod tests { use super::*; #[test] - fn reads_go_export_and_round_trips() { - let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/profiles.link"); - assert!(decrypt_and_import(&fx, "nope").is_err()); - let ps = decrypt_and_import(&fx, "export-pass").unwrap(); - assert_eq!(ps.len(), 6); - assert_eq!(ps[0].ssh.as_ref().unwrap().jump_hosts[0].port, 2222); + fn round_trips() { + let mut p = Profile::new(crate::models::Protocol::Ssh); + p.host = "box".into(); + let ps = vec![p]; let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id())); encrypt_and_export(&ps, "pw", &out).unwrap(); + assert!(decrypt_and_import(&out, "nope").is_err()); assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps); std::fs::remove_file(out).unwrap(); } diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index f4563bc..daf7960 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -1,5 +1,5 @@ -//! Data model shared by storage, the session manager and the UI. Field names serialise exactly as -//! the Go structs did, so database JSON columns and `.link`/`.nexus` exports round-trip unchanged. +//! Data model shared by storage, the session manager and the UI. Field names are the JSON keys +//! used in the database's config columns and in `.nexus` exports. use chrono::{DateTime, FixedOffset, Local}; use serde::{Deserialize, Deserializer, Serialize}; diff --git a/rust/core/src/recording.rs b/rust/core/src/recording.rs index 9c3fe03..bfbdb20 100644 --- a/rust/core/src/recording.rs +++ b/rust/core/src/recording.rs @@ -380,21 +380,6 @@ mod tests { d } - #[test] - fn reads_go_fixture() { - let store = Store::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/recordings")); - let (hdr, events) = store.get("rec1").unwrap(); - assert_eq!((hdr.version, hdr.width, hdr.height, hdr.timestamp), (2, 80, 24, 1791633486)); - assert_eq!(hdr.title, "Fixture — host"); - assert_eq!(hdr.env["TERM"], "xterm-256color"); - assert_eq!(events.len(), 2); - assert_eq!((events[0].kind.as_str(), events[0].data.as_str()), ("o", "hello \x1b[1mworld\x1b[0m\r\n")); - assert_eq!((events[1].kind.as_str(), events[1].data.as_str()), ("i", "ls\r")); - let metas = store.list().unwrap(); - assert_eq!(metas.len(), 1); - assert_eq!(metas[0].duration, 0.000212149); - } - #[test] fn write_and_close() { let dir = tmp("write"); diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index 9df2ca1..42214d1 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -274,7 +274,7 @@ impl Service { Ok(path) } - /// Imports a `.nexus` (or legacy `.link`) export. + /// Imports a `.nexus` export. pub fn import_encrypted(&self, path: &Path, password: &str, strategy: &str) -> Result { let ps = exportimport::decrypt_and_import(path, password)?; self.import(ps, strategy) diff --git a/rust/core/src/storage.rs b/rust/core/src/storage.rs index 2f0f2ea..5c7afd0 100644 --- a/rust/core/src/storage.rs +++ b/rust/core/src/storage.rs @@ -464,43 +464,69 @@ mod tests { } #[test] - fn opens_go_database_and_migrates() { - let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/link.db"); + fn profiles_and_app_state_round_trip() { let tmp = std::env::temp_dir().join(format!("nexus-store-{}.db", std::process::id())); - std::fs::copy(fx, &tmp).unwrap(); let s = Store::open(&tmp).unwrap(); - let ps = s.list_profiles().unwrap(); - assert_eq!(ps.len(), 6); - let ssh = ps.iter().find(|p| p.protocol == Protocol::Ssh).unwrap(); - assert!(ssh.favourite); - assert_eq!(ssh.tags, ["fixture", "ssh"]); - assert_eq!(ssh.last_connected.map(|t| fmt_time(&t)).as_deref(), Some("2026-01-02T03:04:05Z")); - let c = ssh.ssh.as_ref().unwrap(); - assert_eq!(c.env_vars["LANG"], "en_GB.UTF-8"); - assert_eq!(c.login_scripts.len(), 2); - assert_eq!(c.port_forwards[0].bind_port, 8080); - let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap(); - assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]); - assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc"); - // Known hosts written by the Go app can be removed by id; unknown ids are an error. + let mut ssh = Profile::new(Protocol::Ssh); + ssh.host = "box".into(); + ssh.username = "me".into(); + ssh.favourite = true; + ssh.tags = vec!["a".into(), "b".into()]; + let c = ssh.ssh.as_mut().unwrap(); + c.env_vars.insert("LANG".into(), "en_GB.UTF-8".into()); + c.login_scripts = vec![LoginScriptStep { kind: "wait".into(), value: "$".into(), timeout: 5 }]; + c.port_forwards = vec![PortForward { id: "f1".into(), bind_port: 8080, dest_host: "db".into(), dest_port: 5432, enabled: true, ..Default::default() }]; + ssh.normalize(); + s.create_profile(&ssh).unwrap(); + let ssh = ssh.with_secs(); + assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh); + s.update_profile(&ssh).unwrap(); + assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh, "updates are lossless"); + let mut serial = Profile::new(Protocol::Serial); + serial.host = "/dev/ttyUSB0".into(); + serial.normalize(); + s.create_profile(&serial).unwrap(); + assert_eq!(s.list_profiles().unwrap().len(), 2); + + s.save_irc_last_rooms(&ssh.id, &["#rust".into(), "#nexus".into()]).unwrap(); + assert_eq!(s.irc_last_rooms(&ssh.id).unwrap(), ["#rust", "#nexus"]); + let t = parse_time("2026-01-02T03:04:05Z"); + let kh = KnownHost { + id: "k1".into(), + host: "box".into(), + port: 22, + key_type: "ssh-ed25519".into(), + fingerprint: "SHA256:abc".into(), + public_key: String::new(), + first_seen: t, + last_seen: t, + trusted: true, + }; + s.save_known_host(&kh).unwrap(); let kh = s.list_known_hosts().unwrap().remove(0); + assert_eq!(kh.fingerprint, "SHA256:abc"); s.delete_known_host(&kh.id).unwrap(); assert!(s.list_known_hosts().unwrap().is_empty()); - assert!(s.delete_known_host(&kh.id).is_err()); - s.save_known_host(&kh).unwrap(); + assert!(s.delete_known_host(&kh.id).is_err(), "unknown ids are an error"); + s.create_snippet(&Snippet { + id: "s1".into(), + name: "up".into(), + command: "uptime".into(), + description: String::new(), + folder: String::new(), + tags: vec![], + created_at: t, + updated_at: t, + }) + .unwrap(); assert_eq!(s.list_snippets().unwrap()[0].command, "uptime"); + let prefs = AppPreferences { terminal_theme: "dracula".into(), ..Default::default() }; + s.save_preferences(&prefs).unwrap(); assert_eq!(s.preferences().terminal_theme, "dracula"); - // Migration 007 lets serial profiles in. - let mut p = Profile::new(Protocol::Serial); - p.host = "/dev/ttyUSB0".into(); - p.normalize(); - s.create_profile(&p).unwrap(); - assert_eq!(s.get_profile(&p.id).unwrap(), p.clone().with_secs()); - // Round trip of an existing row is lossless. - s.update_profile(ssh).unwrap(); - assert_eq!(&s.get_profile(&ssh.id).unwrap(), ssh); drop(s); - let _ = std::fs::remove_file(&tmp); + for ext in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{ext}", tmp.display())); + } } impl Profile { diff --git a/rust/core/tests/fixtures/README.md b/rust/core/tests/fixtures/README.md deleted file mode 100644 index 19c1cc8..0000000 --- a/rust/core/tests/fixtures/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Compatibility fixtures - -Generated by the last Go build of Alchementary Link with `cmd/genfixtures` (see commit f588a79, -`git show f588a79:cmd/genfixtures/main.go`). Nexus Terminal must keep reading them unchanged: - -- `link.db` — a database through migration 006 with every protocol, a known host, a snippet, - IRC channel state and preferences. -- `profiles.link` — an encrypted export with password `export-pass`. -- `recordings/rec1.cast` — an asciicast v2 recording. - -Don't regenerate them; they are the reference for data written by the old app. diff --git a/rust/core/tests/fixtures/link.db b/rust/core/tests/fixtures/link.db deleted file mode 100644 index ecc833df73e3ad080eb14bfb539665bcefbf9359..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65536 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