fix: require explicit Workspace repository key
This commit is contained in:
@@ -11186,6 +11186,17 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<WorkspaceCreateRequest>(serde_json::json!({
|
||||||
|
"operation_key": "workspace-create-1",
|
||||||
|
"display_name": "Workspace",
|
||||||
|
"repository": {
|
||||||
|
"uri": "/srv/repositories/platform"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.is_err(),
|
||||||
|
"the API wire shape must not supply a default Repository key"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use ratatui::style::{Modifier, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::{self, IsTerminal, Write};
|
use std::io::{self, BufRead, IsTerminal, Write};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
type PickerResult<T> = Result<T, Box<dyn Error>>;
|
type PickerResult<T> = Result<T, Box<dyn Error>>;
|
||||||
@@ -179,18 +179,47 @@ fn prompt_create_request() -> PickerResult<Option<CreateBackendWorkspaceRequest>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||||
println!("Create Workspace (leave display name empty to cancel)");
|
let stdin = io::stdin();
|
||||||
let display_name = prompt_line("Workspace display name: ")?;
|
let stdout = io::stdout();
|
||||||
|
prompt_create_request_with_io(&mut stdin.lock(), &mut stdout.lock())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_create_request_with_io(
|
||||||
|
input: &mut impl BufRead,
|
||||||
|
output: &mut impl Write,
|
||||||
|
) -> PickerResult<Option<CreateBackendWorkspaceRequest>> {
|
||||||
|
writeln!(
|
||||||
|
output,
|
||||||
|
"Create Workspace (leave display name empty to cancel)"
|
||||||
|
)?;
|
||||||
|
let Some(display_name) = prompt_line(input, output, "Workspace display name: ")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
if display_name.is_empty() {
|
if display_name.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let uri = prompt_line("Initial repository absolute path/URI: ")?;
|
let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
if uri.is_empty() {
|
if uri.is_empty() {
|
||||||
println!("Repository path/URI is required.");
|
writeln!(output, "Repository path/URI is required.")?;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let repository_key = prompt_line("Repository key [main]: ")?;
|
let repository_key = loop {
|
||||||
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if repository_key.is_empty() {
|
||||||
|
writeln!(output, "Repository key is required.")?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break repository_key;
|
||||||
|
};
|
||||||
|
let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
let operation_key = format!(
|
let operation_key = format!(
|
||||||
"tui-workspace-create-{}-{}",
|
"tui-workspace-create-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
@@ -204,27 +233,37 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
|||||||
display_name,
|
display_name,
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
uri,
|
uri,
|
||||||
repository_key: if repository_key.is_empty() {
|
repository_key,
|
||||||
"main".to_string()
|
|
||||||
} else {
|
|
||||||
repository_key
|
|
||||||
},
|
|
||||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_line(prompt: &str) -> PickerResult<String> {
|
fn prompt_line(
|
||||||
print!("{prompt}");
|
input: &mut impl BufRead,
|
||||||
io::stdout().flush()?;
|
output: &mut impl Write,
|
||||||
|
prompt: &str,
|
||||||
|
) -> PickerResult<Option<String>> {
|
||||||
|
write!(output, "{prompt}")?;
|
||||||
|
output.flush()?;
|
||||||
let mut value = String::new();
|
let mut value = String::new();
|
||||||
io::stdin().read_line(&mut value)?;
|
if input.read_line(&mut value)? == 0 {
|
||||||
Ok(value.trim().to_string())
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(value.trim().to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
fn prompt_create(input: &str) -> (Option<CreateBackendWorkspaceRequest>, String) {
|
||||||
|
let mut input = Cursor::new(input.as_bytes());
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let request = prompt_create_request_with_io(&mut input, &mut output).unwrap();
|
||||||
|
(request, String::from_utf8(output).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
|
fn picker_actions_distinguish_switch_refresh_create_and_cancel() {
|
||||||
@@ -237,4 +276,47 @@ mod tests {
|
|||||||
WorkspacePickerAction::Cancel
|
WorkspacePickerAction::Cancel
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_creation_preserves_an_explicit_non_main_repository_key() {
|
||||||
|
let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n");
|
||||||
|
let request = request.expect("complete input should create a request");
|
||||||
|
|
||||||
|
assert_eq!(request.display_name, "Platform");
|
||||||
|
assert_eq!(request.repository.uri, "/srv/platform");
|
||||||
|
assert_eq!(request.repository.repository_key, "platform");
|
||||||
|
assert_eq!(request.repository.default_ref.as_deref(), Some("develop"));
|
||||||
|
assert!(output.contains("Repository key (required): "));
|
||||||
|
assert!(!output.contains("Repository key [main]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_creation_reprompts_for_blank_repository_keys() {
|
||||||
|
let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n");
|
||||||
|
let request = request.expect("a later explicit key should create a request");
|
||||||
|
|
||||||
|
assert_eq!(request.repository.repository_key, "platform");
|
||||||
|
assert_eq!(output.matches("Repository key is required.").count(), 2);
|
||||||
|
assert_eq!(output.matches("Repository key (required): ").count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_creation_accepts_explicit_main_repository_key() {
|
||||||
|
let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.unwrap().repository.repository_key,
|
||||||
|
"main",
|
||||||
|
"main remains valid when the creator enters it explicitly"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_creation_cancels_on_eof_instead_of_retrying_forever() {
|
||||||
|
let (request, output) = prompt_create("Workspace\n/srv/repository\n \n");
|
||||||
|
|
||||||
|
assert!(request.is_none());
|
||||||
|
assert_eq!(output.matches("Repository key is required.").count(), 1);
|
||||||
|
assert_eq!(output.matches("Repository key (required): ").count(), 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ mod tests {
|
|||||||
display_name: "Workspace A".to_string(),
|
display_name: "Workspace A".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: InitialRepositoryIntent {
|
||||||
uri: repository.path().display().to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
repository_key: "main".to_string(),
|
repository_key: "platform".to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -322,13 +322,11 @@ mod tests {
|
|||||||
assert_eq!(signing_identity.algorithm, "ed25519");
|
assert_eq!(signing_identity.algorithm, "ed25519");
|
||||||
assert!(signing_identity.public_key.is_some());
|
assert!(signing_identity.public_key.is_some());
|
||||||
assert!(signing_identity.public_key_fingerprint.is_some());
|
assert!(signing_identity.public_key_fingerprint.is_some());
|
||||||
assert_eq!(
|
let repositories = store
|
||||||
store
|
.list_repositories(&created.workspace.workspace_id)
|
||||||
.list_repositories(&created.workspace.workspace_id)
|
.unwrap();
|
||||||
.unwrap()
|
assert_eq!(repositories.len(), 1);
|
||||||
.len(),
|
assert_eq!(repositories[0].repository_key, "platform");
|
||||||
1
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.load_workspace_config(&created.workspace.workspace_id)
|
.load_workspace_config(&created.workspace.workspace_id)
|
||||||
@@ -337,6 +335,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_rejects_blank_repository_key_without_persisting_a_workspace() {
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
let service = WorkspaceCatalogService::new(
|
||||||
|
store.clone(),
|
||||||
|
Arc::new(
|
||||||
|
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let repository = git_repository();
|
||||||
|
let request = WorkspaceCreateRequest {
|
||||||
|
operation_key: "blank-repository-key".to_string(),
|
||||||
|
display_name: "Workspace A".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
uri: repository.path().display().to_string(),
|
||||||
|
repository_key: " ".to_string(),
|
||||||
|
default_ref: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = service
|
||||||
|
.create(request, owner_account(store.as_ref()))
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert!(error.contains("invalid Repository key"), "{error}");
|
||||||
|
assert!(store.list_workspaces().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+31
-2
@@ -2986,7 +2986,7 @@ backend = "shared"
|
|||||||
"--display-name".to_string(),
|
"--display-name".to_string(),
|
||||||
"Workspace A".to_string(),
|
"Workspace A".to_string(),
|
||||||
"--repository-key".to_string(),
|
"--repository-key".to_string(),
|
||||||
"main".to_string(),
|
"platform".to_string(),
|
||||||
"--repository".to_string(),
|
"--repository".to_string(),
|
||||||
repository.path().display().to_string(),
|
repository.path().display().to_string(),
|
||||||
"--default-ref".to_string(),
|
"--default-ref".to_string(),
|
||||||
@@ -3000,7 +3000,7 @@ backend = "shared"
|
|||||||
};
|
};
|
||||||
assert_eq!(options.backend_url, "http://backend.example");
|
assert_eq!(options.backend_url, "http://backend.example");
|
||||||
assert_eq!(options.display_name, "Workspace A");
|
assert_eq!(options.display_name, "Workspace A");
|
||||||
assert_eq!(options.repository_key, "main");
|
assert_eq!(options.repository_key, "platform");
|
||||||
assert_eq!(options.default_ref.as_deref(), Some("develop"));
|
assert_eq!(options.default_ref.as_deref(), Some("develop"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
options.repository_root,
|
options.repository_root,
|
||||||
@@ -3009,6 +3009,35 @@ backend = "shared"
|
|||||||
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_parsing_rejects_an_omitted_repository_key() {
|
||||||
|
let repository = tempfile::tempdir().unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["init", "-q"])
|
||||||
|
.current_dir(repository.path())
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
let resolver = FixedCliConnectionResolver {
|
||||||
|
backend_url: "http://unused.example",
|
||||||
|
};
|
||||||
|
let args = vec![
|
||||||
|
"--backend".to_string(),
|
||||||
|
"http://backend.example".to_string(),
|
||||||
|
"init".to_string(),
|
||||||
|
"--display-name".to_string(),
|
||||||
|
"Workspace A".to_string(),
|
||||||
|
"--repository".to_string(),
|
||||||
|
repository.path().display().to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let error = parse_args_slice_with_connection_resolver(&args, &resolver)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert_eq!(error, "yoi init requires --repository-key KEY");
|
||||||
|
assert!(!repository.path().join(".yoi/workspace.toml").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
|
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void | Promise<void>): void;
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
readTextFile(path: URL): Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
function assertEquals(actual: unknown, expected: unknown): void {
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -88,7 +93,7 @@ Deno.test("workspace creation preserves caller-owned operation key across retry"
|
|||||||
operation_key: "web-create-1",
|
operation_key: "web-create-1",
|
||||||
display_name: "Alpha",
|
display_name: "Alpha",
|
||||||
repository: {
|
repository: {
|
||||||
repository_key: "main",
|
repository_key: "platform",
|
||||||
uri: "/srv/alpha",
|
uri: "/srv/alpha",
|
||||||
default_ref: "develop",
|
default_ref: "develop",
|
||||||
},
|
},
|
||||||
@@ -113,3 +118,27 @@ Deno.test("workspace creation preserves caller-owned operation key across retry"
|
|||||||
);
|
);
|
||||||
assertEquals(bodies, [request, request]);
|
assertEquals(bodies, [request, request]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace creation form starts with an explicit required Repository key", async () => {
|
||||||
|
const source = await Deno.readTextFile(
|
||||||
|
new URL("../src/routes/+page.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
source.includes('let repositoryKey = $state("");'),
|
||||||
|
"the form must start without an implicit Repository key",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("<input bind:value={repositoryKey} required"),
|
||||||
|
"the Browser must require creator input for the Repository key",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("repository_key: normalized.repositoryKey"),
|
||||||
|
"the Browser must submit the creator-provided Repository key",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!source.includes('repositoryKey || "main"') &&
|
||||||
|
!source.includes("repositoryKey || 'main'"),
|
||||||
|
"the Browser must not fall back to main",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user