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:
+1
-1
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Vendored
-11
@@ -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.
|
|
||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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"]
|
|
||||||
Reference in New Issue
Block a user