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.
This commit is contained in:
Andy Dixon
2026-10-12 10:10:08 +01:00
parent 9ebfe57301
commit 4bd12bfb03
13 changed files with 69 additions and 74 deletions
+1 -1
View File
@@ -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. - 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. - 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. - 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. - Plain JSON export and import, with secrets redacted unless you choose to include them.
- Choose import behaviour for duplicates: skip, overwrite or duplicate. - Choose import behaviour for duplicates: skip, overwrite or duplicate.
+1 -1
View File
@@ -379,7 +379,7 @@ Connections, disconnections, profile changes, credential access, imports and exp
## 13. Import and export ## 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 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. - **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. - **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.
+1 -1
View File
@@ -864,7 +864,7 @@ When exporting profiles, encrypted export (a `.nexus` file) protects the file wi
"## Exporting Profiles "## 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. 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 ## 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 ## 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.", 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.",
), ),
+2 -2
View File
@@ -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::settings::{Job, poll};
use crate::state::{Request, Shared}; use crate::state::{Request, Shared};
@@ -54,7 +54,7 @@ impl Transfer {
pub fn import_encrypted(&mut self) { pub fn import_encrypted(&mut self) {
if let Some(path) = 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() }); self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() });
} }
+6 -8
View File
@@ -1,6 +1,5 @@
//! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]` //! 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 //! over a JSON bundle, with the vault's Argon2id parameters.
//! Link are the same format.
use crate::Result; use crate::Result;
use crate::models::Profile; use crate::models::Profile;
@@ -76,14 +75,13 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn reads_go_export_and_round_trips() { fn round_trips() {
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/profiles.link"); let mut p = Profile::new(crate::models::Protocol::Ssh);
assert!(decrypt_and_import(&fx, "nope").is_err()); p.host = "box".into();
let ps = decrypt_and_import(&fx, "export-pass").unwrap(); let ps = vec![p];
assert_eq!(ps.len(), 6);
assert_eq!(ps[0].ssh.as_ref().unwrap().jump_hosts[0].port, 2222);
let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id())); let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id()));
encrypt_and_export(&ps, "pw", &out).unwrap(); encrypt_and_export(&ps, "pw", &out).unwrap();
assert!(decrypt_and_import(&out, "nope").is_err());
assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps); assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps);
std::fs::remove_file(out).unwrap(); std::fs::remove_file(out).unwrap();
} }
+2 -2
View File
@@ -1,5 +1,5 @@
//! Data model shared by storage, the session manager and the UI. Field names serialise exactly as //! Data model shared by storage, the session manager and the UI. Field names are the JSON keys
//! the Go structs did, so database JSON columns and `.link`/`.nexus` exports round-trip unchanged. //! used in the database's config columns and in `.nexus` exports.
use chrono::{DateTime, FixedOffset, Local}; use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
-15
View File
@@ -380,21 +380,6 @@ mod tests {
d 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] #[test]
fn write_and_close() { fn write_and_close() {
let dir = tmp("write"); let dir = tmp("write");
+1 -1
View File
@@ -274,7 +274,7 @@ impl Service {
Ok(path) 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<usize> { pub fn import_encrypted(&self, path: &Path, password: &str, strategy: &str) -> Result<usize> {
let ps = exportimport::decrypt_and_import(path, password)?; let ps = exportimport::decrypt_and_import(path, password)?;
self.import(ps, strategy) self.import(ps, strategy)
+55 -29
View File
@@ -464,43 +464,69 @@ mod tests {
} }
#[test] #[test]
fn opens_go_database_and_migrates() { fn profiles_and_app_state_round_trip() {
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/link.db");
let tmp = std::env::temp_dir().join(format!("nexus-store-{}.db", std::process::id())); 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 s = Store::open(&tmp).unwrap();
let ps = s.list_profiles().unwrap(); let mut ssh = Profile::new(Protocol::Ssh);
assert_eq!(ps.len(), 6); ssh.host = "box".into();
let ssh = ps.iter().find(|p| p.protocol == Protocol::Ssh).unwrap(); ssh.username = "me".into();
assert!(ssh.favourite); ssh.favourite = true;
assert_eq!(ssh.tags, ["fixture", "ssh"]); ssh.tags = vec!["a".into(), "b".into()];
assert_eq!(ssh.last_connected.map(|t| fmt_time(&t)).as_deref(), Some("2026-01-02T03:04:05Z")); let c = ssh.ssh.as_mut().unwrap();
let c = ssh.ssh.as_ref().unwrap(); c.env_vars.insert("LANG".into(), "en_GB.UTF-8".into());
assert_eq!(c.env_vars["LANG"], "en_GB.UTF-8"); c.login_scripts = vec![LoginScriptStep { kind: "wait".into(), value: "$".into(), timeout: 5 }];
assert_eq!(c.login_scripts.len(), 2); c.port_forwards = vec![PortForward { id: "f1".into(), bind_port: 8080, dest_host: "db".into(), dest_port: 5432, enabled: true, ..Default::default() }];
assert_eq!(c.port_forwards[0].bind_port, 8080); ssh.normalize();
let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap(); s.create_profile(&ssh).unwrap();
assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]); let ssh = ssh.with_secs();
assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc"); assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh);
// Known hosts written by the Go app can be removed by id; unknown ids are an error. 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); let kh = s.list_known_hosts().unwrap().remove(0);
assert_eq!(kh.fingerprint, "SHA256:abc");
s.delete_known_host(&kh.id).unwrap(); s.delete_known_host(&kh.id).unwrap();
assert!(s.list_known_hosts().unwrap().is_empty()); assert!(s.list_known_hosts().unwrap().is_empty());
assert!(s.delete_known_host(&kh.id).is_err()); assert!(s.delete_known_host(&kh.id).is_err(), "unknown ids are an error");
s.save_known_host(&kh).unwrap(); 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"); 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"); 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); drop(s);
let _ = std::fs::remove_file(&tmp); for ext in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{ext}", tmp.display()));
}
} }
impl Profile { impl Profile {
-11
View File
@@ -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.
Binary file not shown.
Binary file not shown.
-3
View File
@@ -1,3 +0,0 @@
{"version":2,"width":80,"height":24,"timestamp":1791633486,"title":"Fixture — host","env":{"TERM":"xterm-256color"}}
[0.000139144,"o","hello \u001b[1mworld\u001b[0m\r\n"]
[0.000212149,"i","ls\r"]