tempspace-cli 1.2.8__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tempspace-cli
3
- Version: 1.2.8
3
+ Version: 1.3.0
4
4
  Summary: A command-line tool for uploading files to Tempspace.
5
5
  Author-email: Tempspace <mcbplay1@gmail.com>
6
6
  License: MIT License
@@ -4,8 +4,15 @@ import os
4
4
  import sys
5
5
  import hashlib
6
6
  import multiprocessing
7
-
8
7
  import math
8
+ import re
9
+ import shutil
10
+ import tempfile
11
+ import json
12
+ import uuid
13
+ from pathlib import Path
14
+ from urllib.parse import unquote
15
+
9
16
  from rich.console import Console
10
17
  from rich.panel import Panel
11
18
  from rich.table import Table
@@ -13,16 +20,62 @@ from rich import box
13
20
  import qrcode
14
21
  from rich.prompt import Prompt, Confirm
15
22
  from rich.progress import Progress, BarColumn, TextColumn, TransferSpeedColumn, TimeRemainingColumn
16
-
17
23
  from rich.live import Live
18
- import shutil
19
- import tempfile
20
24
  from requests.adapters import HTTPAdapter
21
25
  from urllib3.util.retry import Retry
22
26
 
23
- # Default configuration
24
27
  DEFAULT_SERVER_URL = "https://tempspace.needrp.net"
25
- CHUNK_SIZE = 1024 * 1024 # 1MB
28
+ CHUNK_SIZE = 1024 * 1024
29
+ REQUEST_TIMEOUT = 30
30
+ VALID_HOURS = [1, 3, 6, 12, 24, 48, 168, 336, 720]
31
+ CONFIG_DIR = Path.home() / ".tempspace"
32
+ CONFIG_FILE = CONFIG_DIR / "config.json"
33
+ HISTORY_FILE = CONFIG_DIR / "history.json"
34
+ MAX_HISTORY = 50
35
+
36
+
37
+ def load_config() -> dict:
38
+ """Loads configuration from ~/.tempspace/config.json."""
39
+ if CONFIG_FILE.exists():
40
+ try:
41
+ with open(CONFIG_FILE, 'r') as f:
42
+ return json.load(f)
43
+ except Exception:
44
+ pass
45
+ return {}
46
+
47
+
48
+ def save_config(config: dict):
49
+ """Saves configuration to ~/.tempspace/config.json."""
50
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
51
+ with open(CONFIG_FILE, 'w') as f:
52
+ json.dump(config, f, indent=2)
53
+
54
+
55
+ def load_history() -> list:
56
+ """Loads upload history from ~/.tempspace/history.json."""
57
+ if HISTORY_FILE.exists():
58
+ try:
59
+ with open(HISTORY_FILE, 'r') as f:
60
+ return json.load(f)
61
+ except Exception:
62
+ pass
63
+ return []
64
+
65
+
66
+ def save_history(history: list):
67
+ """Saves upload history to ~/.tempspace/history.json."""
68
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
69
+ history = history[:MAX_HISTORY]
70
+ with open(HISTORY_FILE, 'w') as f:
71
+ json.dump(history, f, indent=2)
72
+
73
+
74
+ def add_to_history(entry: dict):
75
+ """Adds an upload entry to history."""
76
+ history = load_history()
77
+ history.insert(0, entry)
78
+ save_history(history)
26
79
 
27
80
  def parse_time(time_str: str) -> int:
28
81
  """Parses a user-provided time string into a total number of hours.
@@ -54,6 +107,11 @@ def parse_time(time_str: str) -> int:
54
107
  except ValueError:
55
108
  return None
56
109
 
110
+
111
+ def validate_hours(hours: int) -> bool:
112
+ """Validates that hours is an accepted value by the server."""
113
+ return hours in VALID_HOURS
114
+
57
115
  def format_size(size_bytes: int) -> str:
58
116
  """Converts a file size in bytes into a human-readable string.
59
117
 
@@ -92,7 +150,7 @@ def calculate_file_hash(filepath: str) -> str:
92
150
 
93
151
 
94
152
  def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504]):
95
- """Creates a requests Session with automatic retries."""
153
+ """Creates a requests Session with automatic retries and default timeout."""
96
154
  session = requests.Session()
97
155
  retry = Retry(
98
156
  total=retries,
@@ -104,74 +162,51 @@ def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[500, 502, 5
104
162
  adapter = HTTPAdapter(max_retries=retry)
105
163
  session.mount('http://', adapter)
106
164
  session.mount('https://', adapter)
165
+ session.request_timeout = REQUEST_TIMEOUT
107
166
  return session
108
167
 
109
168
 
110
- def zip_directory(path: str) -> str:
111
- """Zips a directory and returns the path to the temporary zip file."""
112
- # Create a temporary directory to store the zip
169
+ def zip_directory(path: str) -> tuple[str, str]:
170
+ """Zips a directory and returns the path to the zip file and temp directory."""
113
171
  temp_dir = tempfile.mkdtemp()
114
- # Use the original folder name for the zip file
115
172
  base_name = os.path.basename(os.path.normpath(path))
116
173
  archive_base = os.path.join(temp_dir, base_name)
117
-
118
174
  shutil.make_archive(archive_base, 'zip', path)
119
- return archive_base + ".zip"
175
+ return archive_base + ".zip", temp_dir
120
176
 
121
177
 
122
178
  def download_file(console, url, password=None):
123
179
  """Downloads a file from Tempspace."""
124
180
  session = get_retry_session()
125
-
181
+
126
182
  try:
127
- # Check if it's a valid Tempspace URL and extract ID logic if needed,
128
- # but for now we'll assume the URL is the full download link or similar.
129
- # Actually, the user provides the "Download Link" which usually leads to a landing page.
130
- # The CLI needs to handle the actual file download.
131
- # If the URL is http://site/DOWLOAD_ID, the direct download might be different.
132
- # Let's assume the user passes the direct download link or we try to download from it.
133
- # However, Tempspace likely has a landing page.
134
- # If the user gives the landing page URL, we might need to scrape or use an API.
135
- # Given I don't see the server code, I'll assume the user might provide a direct file URL
136
- # or the tool should try to GET the URL.
137
- # If the server has an API like /api/download/<id>?password=..., that would be best.
138
- # Looking at upload_file response: `download_link = data.get('url')`.
139
- # Code audit earlier showed `DEFAULT_SERVER_URL`.
140
- # Use regex to extract ID from URL if possible, or just try GET.
141
-
142
- # Simple approach: GET the URL. If it initiates a download (headers), good.
143
-
144
- session.head(url, allow_redirects=True)
145
- # Check headers for content-disposition or just proceed
146
-
147
- if password:
148
- # If password is needed, headers or query param might be required.
149
- # Since I don't know the exact server auth mechanism for downloads,
150
- # I will try passing it as a query param 'password' or header 'X-Password'.
151
- # Better: ask the user to enter it if the server responds 401/403.
152
- pass
153
-
154
- # For a streaming download
155
- response = session.get(url, stream=True, params={'password': password} if password else None)
183
+ response = session.get(url, stream=True, params={'password': password} if password else None, timeout=REQUEST_TIMEOUT)
184
+
185
+ if response.status_code in (401, 403):
186
+ if password:
187
+ console.print(Panel("[bold red]Error:[/] Incorrect password.", border_style="red"))
188
+ return
189
+ password = Prompt.ask("This file is password protected. Enter password", password=True)
190
+ response = session.get(url, stream=True, params={'password': password}, timeout=REQUEST_TIMEOUT)
191
+
156
192
  response.raise_for_status()
157
-
193
+
158
194
  total_size = int(response.headers.get('content-length', 0))
159
195
  disposition = response.headers.get('content-disposition')
160
- # Try to get filename from Content-Disposition
196
+
161
197
  filename = None
162
198
  if disposition:
163
- import re
164
- # specific regex for filename="example.txt" or filename=example.txt
165
- fname = re.findall(r'filename=["\']?([^"\';]+)["\']?', disposition)
166
- if fname:
167
- filename = fname[0]
168
-
169
- # Fallback to URL if no filename found from headers
199
+ fname = re.findall(r"filename\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?", disposition)
200
+ for match in fname:
201
+ filename = match[0] or match[1]
202
+ if filename:
203
+ filename = unquote(filename)
204
+ break
205
+
170
206
  if not filename:
171
- from urllib.parse import unquote
172
- parsed_path = unquote(url.split("?")[0])
173
- filename = parsed_path.split("/")[-1]
174
-
207
+ parsed_path = unquote(url.split("?")[0])
208
+ filename = parsed_path.split("/")[-1]
209
+
175
210
  if not filename:
176
211
  filename = "downloaded_file"
177
212
 
@@ -182,21 +217,25 @@ def download_file(console, url, password=None):
182
217
  TransferSpeedColumn(), "•",
183
218
  TimeRemainingColumn(),
184
219
  )
185
-
220
+
186
221
  with Live(Panel(progress, title="[cyan]Downloading[/cyan]", border_style="cyan", title_align="left")):
187
222
  task_id = progress.add_task(filename, total=total_size)
188
223
  with open(filename, 'wb') as f:
189
224
  for chunk in response.iter_content(CHUNK_SIZE):
190
225
  f.write(chunk)
191
226
  progress.update(task_id, advance=len(chunk))
192
-
227
+
193
228
  console.print(Panel(f"[bold green]Download successful![/] Saved to '{filename}'", border_style="green"))
194
229
 
230
+ except requests.exceptions.Timeout:
231
+ console.print(Panel("[bold red]Error:[/] Download timed out.", border_style="red"))
232
+ except requests.exceptions.ConnectionError:
233
+ console.print(Panel("[bold red]Error:[/] Connection failed. Check your internet connection.", border_style="red"))
195
234
  except Exception as e:
196
235
  console.print(Panel(f"[bold red]Download failed:[/] {e}", border_style="red"))
197
236
 
198
237
 
199
- def upload_file(console, filepath, hours, password, one_time, qr, url):
238
+ def upload_file(console, filepath, hours, password, one_time, qr, url, client_id=None):
200
239
  """Handles the upload of a single file.
201
240
 
202
241
  Args:
@@ -207,13 +246,20 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
207
246
  one_time (bool): Whether the file should be a one-time download.
208
247
  qr (bool): Whether to display a QR code for the download link.
209
248
  url (str): The base URL of the Tempspace server.
249
+ client_id (str): Optional client ID for file ownership tracking.
210
250
  """
211
- # --- Validate Inputs ---
212
251
  if not os.path.isfile(filepath):
213
252
  console.print(Panel(f"[bold red]Error:[/] File not found at '{filepath}'", title="[bold red]Error[/bold red]", border_style="red"))
214
- return # Return instead of exiting to allow other files to be processed
253
+ return False
254
+
255
+ if password and len(password) < 4:
256
+ console.print(Panel("[bold red]Error:[/] Password must be at least 4 characters.", border_style="red"))
257
+ return False
258
+
259
+ if not validate_hours(hours):
260
+ console.print(Panel(f"[bold red]Error:[/] Invalid expiry time '{hours}'. Valid values: {VALID_HOURS}", border_style="red"))
261
+ return False
215
262
 
216
- # --- Display File Details ---
217
263
  table = Table(title="File Details", show_header=False, box=box.ROUNDED, border_style="cyan")
218
264
  table.add_column("Field", style="bold")
219
265
  table.add_column("Value")
@@ -223,27 +269,21 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
223
269
  table.add_row("Password", "[green]Yes[/green]" if password else "[red]No[/red]")
224
270
  table.add_row("One-Time Download", "[green]Yes[/green]" if one_time else "[red]No[/red]")
225
271
 
226
- # --- Prepare Upload ---
227
272
  upload_url = f"{url.rstrip('/')}"
228
273
  filename = os.path.basename(filepath)
229
274
  file_size = os.path.getsize(filepath)
230
275
 
231
- # --- Calculate Hash ---
232
276
  client_hash = calculate_file_hash(filepath)
233
277
  table.add_row("File Hash", f"[cyan]{client_hash}[/cyan]")
234
278
  console.print(table)
235
279
 
236
-
237
- # --- Chunked Upload ---
238
280
  response = None
239
281
  session = get_retry_session()
240
282
  try:
241
- # 1. Initiate Upload
242
- initiate_response = session.post(f"{upload_url}/upload/initiate")
283
+ initiate_response = session.post(f"{upload_url}/upload/initiate", timeout=REQUEST_TIMEOUT)
243
284
  initiate_response.raise_for_status()
244
285
  upload_id = initiate_response.json()['upload_id']
245
286
 
246
- # 2. Upload Chunks
247
287
  progress = Progress(
248
288
  TextColumn("[bold blue]{task.description}", justify="right"),
249
289
  BarColumn(bar_width=None),
@@ -267,29 +307,37 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
267
307
  chunk_response = session.post(
268
308
  f"{upload_url}/upload/chunk",
269
309
  data=chunk_data,
270
- files=files
310
+ files=files,
311
+ timeout=REQUEST_TIMEOUT
271
312
  )
272
313
  chunk_response.raise_for_status()
273
314
  progress.update(task_id, advance=len(chunk))
274
315
 
275
- # 3. Finalize Upload
276
316
  console.print(Panel("[bold green]Finalizing upload...[/bold green]", border_style="green"))
277
317
  finalize_data = {
278
318
  'upload_id': upload_id,
279
319
  'filename': filename,
280
320
  'hours': str(hours),
281
321
  'one_time': str(one_time).lower(),
282
- 'client_hash': client_hash, # Send the client-side hash for verification
322
+ 'client_hash': client_hash,
283
323
  }
324
+ if client_id:
325
+ finalize_data['client_id'] = client_id
284
326
  if password:
285
327
  finalize_data['password'] = password
286
328
 
287
- response = session.post(f"{upload_url}/upload/finalize", data=finalize_data)
329
+ response = session.post(f"{upload_url}/upload/finalize", data=finalize_data, timeout=REQUEST_TIMEOUT)
288
330
  response.raise_for_status()
289
331
 
332
+ except requests.exceptions.Timeout:
333
+ console.print(Panel("[bold red]Error:[/] Upload timed out.", border_style="red"))
334
+ return False
335
+ except requests.exceptions.ConnectionError:
336
+ console.print(Panel("[bold red]Error:[/] Connection failed. Check your internet connection.", border_style="red"))
337
+ return False
290
338
  except FileNotFoundError:
291
- console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.", title="[bold red]Error[/bold red]", border_style="red"))
292
- return
339
+ console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.", border_style="red"))
340
+ return False
293
341
  except requests.exceptions.RequestException as e:
294
342
  error_message = str(e)
295
343
  if e.response:
@@ -297,13 +345,12 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
297
345
  error_message = e.response.json().get('detail', e.response.text)
298
346
  except Exception:
299
347
  error_message = e.response.text
300
- console.print(Panel(f"[bold red]An error occurred:[/] {error_message}", title="[bold red]Error[/bold red]", border_style="red"))
301
- return
348
+ console.print(Panel(f"[bold red]An error occurred:[/] {error_message}", border_style="red"))
349
+ return False
302
350
  except Exception as e:
303
- console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}", title="[bold red]Error[/bold red]", border_style="red"))
304
- return
351
+ console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}", border_style="red"))
352
+ return False
305
353
 
306
- # --- Handle Response ---
307
354
  if response is not None:
308
355
  if response.status_code == 200:
309
356
  try:
@@ -327,8 +374,17 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
327
374
  qr_code.make(fit=True)
328
375
  qr_code.print_ascii()
329
376
 
377
+ from datetime import datetime
378
+ add_to_history({
379
+ 'filename': filename,
380
+ 'url': download_link,
381
+ 'hash': file_hash,
382
+ 'timestamp': datetime.now().isoformat()
383
+ })
384
+
385
+ return True
386
+
330
387
  except requests.exceptions.JSONDecodeError:
331
- # Fallback for older servers or unexpected plain text responses
332
388
  download_link = response.text.strip()
333
389
  success_panel = Panel(f"[bold green]Upload successful![/bold green]\n\nDownload Link: {download_link}",
334
390
  title="[bold cyan]Success[/bold cyan]", border_style="green")
@@ -339,13 +395,43 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
339
395
  qr_code.add_data(download_link)
340
396
  qr_code.make(fit=True)
341
397
  qr_code.print_ascii()
398
+
399
+ from datetime import datetime
400
+ add_to_history({
401
+ 'filename': filename,
402
+ 'url': download_link,
403
+ 'hash': None,
404
+ 'timestamp': datetime.now().isoformat()
405
+ })
406
+
407
+ return True
342
408
  else:
343
409
  try:
344
410
  error_details = response.json()
345
411
  error_message = error_details.get('detail', 'No details provided.')
346
412
  except requests.exceptions.JSONDecodeError:
347
413
  error_message = response.text
348
- console.print(Panel(f"[bold red]Error:[/] Upload failed with status code {response.status_code}\n[red]Server message:[/] {error_message}", title="[bold red]Error[/bold red]", border_style="red"))
414
+ console.print(Panel(f"[bold red]Error:[/] Upload failed with status code {response.status_code}\n[red]Server message:[/] {error_message}", border_style="red"))
415
+ return False
416
+
417
+ return False
418
+
419
+
420
+ def get_client_id() -> str:
421
+ """Returns a persistent client ID from config, environment, or generates one."""
422
+ client_id = os.environ.get('TEMPSPACE_CLIENT_ID')
423
+ if client_id:
424
+ return client_id
425
+
426
+ config = load_config()
427
+ client_id = config.get('client_id')
428
+ if client_id:
429
+ return client_id
430
+
431
+ client_id = f"cli_{uuid.uuid4().hex[:12]}"
432
+ config['client_id'] = client_id
433
+ save_config(config)
434
+ return client_id
349
435
 
350
436
 
351
437
  def main():
@@ -356,22 +442,52 @@ def main():
356
442
  """
357
443
  console = Console()
358
444
 
359
- # --- Handle Download Command ---
360
445
  if len(sys.argv) > 1 and sys.argv[1] == 'download':
361
446
  parser = argparse.ArgumentParser(description="Download a file from Tempspace.")
362
447
  parser.add_argument("url", help="The URL of the file to download.")
363
448
  parser.add_argument("-p", "--password", help="The password for the file.")
364
-
365
- # We process only the download arguments
449
+
366
450
  if len(sys.argv) == 2 and (sys.argv[1] == '-h' or sys.argv[1] == '--help'):
367
451
  parser.print_help()
368
452
  sys.exit(0)
369
-
453
+
370
454
  args = parser.parse_args(sys.argv[2:])
371
455
  download_file(console, args.url, args.password)
372
456
  return
373
457
 
374
- # --- Header ---
458
+ if len(sys.argv) > 1 and sys.argv[1] == 'history':
459
+ history = load_history()
460
+ if not history:
461
+ console.print(Panel("[yellow]No upload history found.[/yellow]", border_style="yellow"))
462
+ return
463
+
464
+ table = Table(title="Upload History", show_header=True, box=box.ROUNDED, border_style="cyan")
465
+ table.add_column("#", style="dim", width=4)
466
+ table.add_column("Filename", style="bold")
467
+ table.add_column("URL", style="green")
468
+ table.add_column("Date", style="cyan")
469
+
470
+ for i, entry in enumerate(history[:20], 1):
471
+ from datetime import datetime
472
+ date_str = datetime.fromisoformat(entry['timestamp']).strftime("%Y-%m-%d %H:%M")
473
+ table.add_row(str(i), entry['filename'], entry['url'], date_str)
474
+
475
+ console.print(table)
476
+ return
477
+
478
+ if len(sys.argv) > 1 and sys.argv[1] == 'config':
479
+ config = load_config()
480
+ if len(sys.argv) > 2 and sys.argv[2] == 'show':
481
+ console.print(Panel(json.dumps(config, indent=2), title="Config", border_style="cyan"))
482
+ elif len(sys.argv) > 3 and sys.argv[2] == 'set':
483
+ key, value = sys.argv[3].split('=', 1) if '=' in sys.argv[3] else (sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else '')
484
+ config[key] = value
485
+ save_config(config)
486
+ console.print(Panel(f"[green]Set {key}={value}[/green]", border_style="green"))
487
+ else:
488
+ console.print("Usage: tempspace config [show|set KEY=VALUE]")
489
+ return
490
+
375
491
  console.print(Panel("[bold cyan]Tempspace File Uploader[/bold cyan]", expand=False, border_style="blue"))
376
492
 
377
493
  parser = argparse.ArgumentParser(
@@ -391,9 +507,7 @@ def main():
391
507
 
392
508
  filepaths = args.filepaths
393
509
 
394
- # --- Interactive Mode ---
395
510
  if args.it:
396
- # Interactive mode only supports a single file, so we overwrite the filepaths list
397
511
  filepath = Prompt.ask("Enter the path to the file you want to upload")
398
512
  filepaths = [filepath]
399
513
  args.time = Prompt.ask("Set the file's expiration time (e.g., '24h', '7d')", default='24')
@@ -401,7 +515,6 @@ def main():
401
515
  args.one_time = Confirm.ask("Delete the file after the first download?", default=False)
402
516
  args.qr = Confirm.ask("Display a QR code of the download link?", default=False)
403
517
 
404
- # --- Validate Inputs ---
405
518
  if not filepaths:
406
519
  console.print(Panel("[bold red]Error:[/] No file path(s) provided.", title="[bold red]Error[/bold red]", border_style="red"))
407
520
  parser.print_help()
@@ -412,24 +525,41 @@ def main():
412
525
  console.print(Panel(f"[bold red]Error:[/] Invalid time format '{args.time}'. Use formats like '24h', '7d', or '360'.", title="[bold red]Error[/bold red]", border_style="red"))
413
526
  sys.exit(1)
414
527
 
415
- # --- Process each file ---
416
- # --- Process each file ---
528
+ if not validate_hours(hours):
529
+ console.print(Panel(f"[bold red]Error:[/] Invalid expiry time '{hours}'. Valid values: {VALID_HOURS}", title="[bold red]Error[/bold red]", border_style="red"))
530
+ sys.exit(1)
531
+
532
+ if args.password and len(args.password) < 4:
533
+ console.print(Panel("[bold red]Error:[/] Password must be at least 4 characters.", title="[bold red]Error[/bold red]", border_style="red"))
534
+ sys.exit(1)
535
+
536
+ client_id = get_client_id()
537
+ all_success = True
538
+
417
539
  for i, filepath in enumerate(filepaths):
418
540
  actual_path = filepath
419
541
  is_temp = False
420
-
542
+ temp_dir = None
543
+
421
544
  if os.path.isdir(filepath):
422
545
  console.print(f"[bold yellow]Zipping directory '{os.path.basename(filepath)}'...[/]")
423
- actual_path = zip_directory(filepath)
546
+ actual_path, temp_dir = zip_directory(filepath)
424
547
  is_temp = True
425
548
 
426
549
  if len(filepaths) > 1:
427
550
  console.print(f"\n[bold yellow]Uploading file {i+1} of {len(filepaths)}: {os.path.basename(actual_path)}[/bold yellow]\n")
428
-
429
- upload_file(console, actual_path, hours, args.password, args.one_time, args.qr, args.url)
430
-
551
+
552
+ success = upload_file(console, actual_path, hours, args.password, args.one_time, args.qr, args.url, client_id)
553
+ if not success:
554
+ all_success = False
555
+
431
556
  if is_temp:
432
557
  os.remove(actual_path)
558
+ if temp_dir:
559
+ shutil.rmtree(temp_dir, ignore_errors=True)
560
+
561
+ if not all_success:
562
+ sys.exit(1)
433
563
 
434
564
 
435
565
  if __name__ == "__main__":
@@ -7,7 +7,7 @@ packages = ["cli"]
7
7
 
8
8
  [project]
9
9
  name = "tempspace-cli"
10
- version = "1.2.8"
10
+ version = "1.3.0"
11
11
  authors = [
12
12
  { name="Tempspace", email="mcbplay1@gmail.com" },
13
13
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tempspace-cli
3
- Version: 1.2.8
3
+ Version: 1.3.0
4
4
  Summary: A command-line tool for uploading files to Tempspace.
5
5
  Author-email: Tempspace <mcbplay1@gmail.com>
6
6
  License: MIT License
@@ -0,0 +1,270 @@
1
+
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from unittest.mock import MagicMock, patch
9
+ from cli.tempspace import parse_time, format_size, calculate_file_hash, zip_directory, upload_file, download_file, main, validate_hours
10
+
11
+
12
+ # --- Unit Tests ---
13
+
14
+ def test_parse_time():
15
+ assert parse_time("7d") == 168
16
+ assert parse_time("24h") == 24
17
+ assert parse_time("360") == 360
18
+ assert parse_time("1D") == 24
19
+ assert parse_time("2H") == 2
20
+ assert parse_time(" 7d ") == 168
21
+ assert parse_time("invalid") is None
22
+ assert parse_time("1w") is None
23
+
24
+ def test_format_size():
25
+ assert format_size(0) == "0B"
26
+ assert format_size(1023) == "1023.0 B"
27
+ assert format_size(100) == "100.0 B"
28
+ assert format_size(1024) == "1.0 KB"
29
+ assert format_size(1024 * 1024) == "1.0 MB"
30
+ assert format_size(1024 * 1024 * 1024 * 2.5) == "2.5 GB"
31
+
32
+ def test_validate_hours():
33
+ assert validate_hours(24) is True
34
+ assert validate_hours(1) is True
35
+ assert validate_hours(720) is True
36
+ assert validate_hours(999) is False
37
+ assert validate_hours(0) is False
38
+ assert validate_hours(5) is False
39
+
40
+ def test_calculate_file_hash():
41
+ with tempfile.NamedTemporaryFile(delete=False) as f:
42
+ f.write(b"hello world")
43
+ filepath = f.name
44
+ try:
45
+ expected_hash = hashlib.sha256(b"hello world").hexdigest()
46
+ assert calculate_file_hash(filepath) == expected_hash
47
+ finally:
48
+ os.remove(filepath)
49
+
50
+ def test_zip_directory():
51
+ temp_dir = tempfile.mkdtemp()
52
+ try:
53
+ os.makedirs(os.path.join(temp_dir, "subdir"))
54
+ with open(os.path.join(temp_dir, "file1.txt"), "w") as f:
55
+ f.write("content1")
56
+
57
+ zip_path, zip_temp_dir = zip_directory(temp_dir)
58
+
59
+ assert os.path.exists(zip_path)
60
+ assert zip_path.endswith(".zip")
61
+ assert os.path.basename(temp_dir) in os.path.basename(zip_path)
62
+ assert os.path.exists(zip_temp_dir)
63
+
64
+ os.remove(zip_path)
65
+ shutil.rmtree(zip_temp_dir)
66
+ finally:
67
+ shutil.rmtree(temp_dir)
68
+
69
+ # --- Mock Tests ---
70
+
71
+ @patch('cli.tempspace.requests.Session')
72
+ def test_upload_file_success(mock_session_cls, capsys):
73
+ mock_session = mock_session_cls.return_value
74
+
75
+ mock_session.post.side_effect = [
76
+ MagicMock(status_code=200, json=lambda: {'upload_id': '123'}),
77
+ MagicMock(status_code=200),
78
+ MagicMock(status_code=200, json=lambda: {'url': 'http://test.com/file', 'hash': 'abc', 'hash_verified': True})
79
+ ]
80
+
81
+ with tempfile.NamedTemporaryFile(delete=False) as f:
82
+ f.write(b"test content")
83
+ filepath = f.name
84
+
85
+ mock_console = MagicMock()
86
+
87
+ try:
88
+ result = upload_file(mock_console, filepath, 24, None, False, False, "http://test-server", "test-client-id")
89
+
90
+ assert result is True
91
+ assert mock_session.post.call_count == 3
92
+ mock_session.post.call_args_list[0][0][0].endswith("/upload/initiate")
93
+ mock_session.post.call_args_list[2][0][0].endswith("/upload/finalize")
94
+
95
+ finally:
96
+ os.remove(filepath)
97
+
98
+ @patch('cli.tempspace.requests.Session')
99
+ def test_upload_file_invalid_hours(mock_session_cls):
100
+ mock_session = mock_session_cls.return_value
101
+
102
+ with tempfile.NamedTemporaryFile(delete=False) as f:
103
+ f.write(b"test content")
104
+ filepath = f.name
105
+
106
+ mock_console = MagicMock()
107
+
108
+ try:
109
+ result = upload_file(mock_console, filepath, 999, None, False, False, "http://test-server")
110
+ assert result is False
111
+ assert mock_session.post.call_count == 0
112
+ finally:
113
+ os.remove(filepath)
114
+
115
+ @patch('cli.tempspace.requests.Session')
116
+ def test_upload_file_short_password(mock_session_cls):
117
+ mock_session = mock_session_cls.return_value
118
+
119
+ with tempfile.NamedTemporaryFile(delete=False) as f:
120
+ f.write(b"test content")
121
+ filepath = f.name
122
+
123
+ mock_console = MagicMock()
124
+
125
+ try:
126
+ result = upload_file(mock_console, filepath, 24, "ab", False, False, "http://test-server")
127
+ assert result is False
128
+ assert mock_session.post.call_count == 0
129
+ finally:
130
+ os.remove(filepath)
131
+
132
+ @patch('cli.tempspace.requests.Session')
133
+ def test_download_file_success(mock_session_cls):
134
+ mock_session = mock_session_cls.return_value
135
+
136
+ content = b"downloaded content"
137
+ mock_response = MagicMock(status_code=200)
138
+ mock_response.headers = {'content-length': str(len(content)), 'content-disposition': 'attachment; filename="server_file.txt"'}
139
+ mock_response.iter_content.return_value = [content]
140
+ mock_session.get.return_value = mock_response
141
+
142
+ mock_console = MagicMock()
143
+
144
+ try:
145
+ download_file(mock_console, "http://test-server/file")
146
+
147
+ assert os.path.exists("server_file.txt")
148
+ with open("server_file.txt", "rb") as f:
149
+ assert f.read() == content
150
+ finally:
151
+ if os.path.exists("server_file.txt"):
152
+ os.remove("server_file.txt")
153
+
154
+
155
+ @patch('sys.argv', ['tempspace', 'file.txt'])
156
+ @patch('cli.tempspace.upload_file')
157
+ def test_main_upload_defaults(mock_upload):
158
+ mock_upload.return_value = True
159
+ with patch('cli.tempspace.os.path.isfile', return_value=True):
160
+ main()
161
+
162
+ mock_upload.assert_called_once()
163
+ args = mock_upload.call_args[0]
164
+ assert args[1] == 'file.txt'
165
+ assert args[2] == 24
166
+ assert args[7] is not None
167
+
168
+ @patch('sys.argv', ['tempspace', 'file.txt', '-t', '1h', '-p', 'secret', '--qr'])
169
+ @patch('cli.tempspace.upload_file')
170
+ def test_main_upload_custom(mock_upload):
171
+ mock_upload.return_value = True
172
+ with patch('cli.tempspace.os.path.isfile', return_value=True):
173
+ main()
174
+
175
+ args = mock_upload.call_args[0]
176
+ assert args[1] == 'file.txt'
177
+ assert args[2] == 1
178
+ assert args[3] == 'secret'
179
+ assert args[5] is True
180
+
181
+ @patch('sys.argv', ['tempspace', 'download', 'http://example.com/file', '-p', 'pass'])
182
+ @patch('cli.tempspace.download_file')
183
+ def test_main_download_command(mock_download):
184
+ main()
185
+ mock_download.assert_called_once()
186
+ args = mock_download.call_args[0]
187
+ assert args[1] == 'http://example.com/file'
188
+ assert args[2] == 'pass'
189
+
190
+
191
+ @patch('cli.tempspace.requests.Session')
192
+ @patch('cli.tempspace.Prompt.ask', return_value='correct_password')
193
+ def test_download_file_prompts_for_password(mock_prompt, mock_session_cls):
194
+ mock_session = mock_session_cls.return_value
195
+
196
+ content = b"secret content"
197
+ mock_response_401 = MagicMock(status_code=401)
198
+ mock_response_200 = MagicMock(status_code=200)
199
+ mock_response_200.headers = {'content-length': str(len(content)), 'content-disposition': 'attachment; filename="secret.txt"'}
200
+ mock_response_200.iter_content.return_value = [content]
201
+ mock_session.get.side_effect = [mock_response_401, mock_response_200]
202
+
203
+ mock_console = MagicMock()
204
+
205
+ try:
206
+ download_file(mock_console, "http://test-server/secret")
207
+
208
+ assert mock_prompt.called
209
+ assert os.path.exists("secret.txt")
210
+ with open("secret.txt", "rb") as f:
211
+ assert f.read() == content
212
+ finally:
213
+ if os.path.exists("secret.txt"):
214
+ os.remove("secret.txt")
215
+
216
+
217
+ def test_load_save_config():
218
+ """Test config file loading and saving."""
219
+ from cli.tempspace import load_config, save_config, CONFIG_DIR, CONFIG_FILE
220
+ import tempfile
221
+
222
+ with tempfile.TemporaryDirectory() as tmpdir:
223
+ import cli.tempspace as cli_module
224
+ original_config_dir = cli_module.CONFIG_DIR
225
+ original_config_file = cli_module.CONFIG_FILE
226
+
227
+ try:
228
+ cli_module.CONFIG_DIR = Path(tmpdir)
229
+ cli_module.CONFIG_FILE = Path(tmpdir) / "config.json"
230
+
231
+ save_config({"client_id": "test-id", "server_url": "http://test.com"})
232
+ config = load_config()
233
+
234
+ assert config["client_id"] == "test-id"
235
+ assert config["server_url"] == "http://test.com"
236
+ finally:
237
+ cli_module.CONFIG_DIR = original_config_dir
238
+ cli_module.CONFIG_FILE = original_config_file
239
+
240
+
241
+ def test_load_save_history():
242
+ """Test history file loading and saving."""
243
+ from cli.tempspace import load_history, save_history, add_to_history
244
+ import tempfile
245
+
246
+ with tempfile.TemporaryDirectory() as tmpdir:
247
+ import cli.tempspace as cli_module
248
+ original_config_dir = cli_module.CONFIG_DIR
249
+ original_history_file = cli_module.HISTORY_FILE
250
+
251
+ try:
252
+ cli_module.CONFIG_DIR = Path(tmpdir)
253
+ cli_module.HISTORY_FILE = Path(tmpdir) / "history.json"
254
+
255
+ save_history([
256
+ {"filename": "test1.txt", "url": "http://test.com/1", "timestamp": "2024-01-01T00:00:00"},
257
+ {"filename": "test2.txt", "url": "http://test.com/2", "timestamp": "2024-01-02T00:00:00"}
258
+ ])
259
+
260
+ history = load_history()
261
+ assert len(history) == 2
262
+ assert history[0]["filename"] == "test1.txt"
263
+
264
+ add_to_history({"filename": "test3.txt", "url": "http://test.com/3", "timestamp": "2024-01-03T00:00:00"})
265
+ history = load_history()
266
+ assert len(history) == 3
267
+ assert history[0]["filename"] == "test3.txt"
268
+ finally:
269
+ cli_module.CONFIG_DIR = original_config_dir
270
+ cli_module.HISTORY_FILE = original_history_file
@@ -3,7 +3,7 @@ from fastapi.testclient import TestClient
3
3
  from main import (
4
4
  app, UPLOAD_DIR, RateLimiter, RATE_LIMIT_UPLOADS, RATE_LIMIT_DOWNLOADS,
5
5
  RATE_LIMIT_WINDOW, cleanup_expired_files, shutdown_event,
6
- is_video, is_text, format_bytes
6
+ is_video, is_text, format_bytes, sanitize_filename, hash_password, verify_password
7
7
  )
8
8
  import os
9
9
  import shutil
@@ -757,3 +757,220 @@ def test_download_content_disposition():
757
757
  # Starlette/FastAPI FileResponse might use filename*=UTF-8''... for safety
758
758
  cd = response.headers["content-disposition"]
759
759
  assert filename in cd
760
+
761
+
762
+ def test_sanitize_filename_path_traversal():
763
+ """Test that sanitize_filename prevents path traversal attacks."""
764
+ assert sanitize_filename("../../../etc/passwd") == "passwd"
765
+ assert sanitize_filename("..\\..\\windows\\system32\\config\\sam") == "sam"
766
+ assert sanitize_filename("normal_file.txt") == "normal_file.txt"
767
+ assert sanitize_filename(" spaces .txt") == "spaces .txt"
768
+ assert sanitize_filename("....") == "unnamed_file"
769
+ assert sanitize_filename("") == "unnamed_file"
770
+ assert sanitize_filename("file\x00.txt") == "file.txt"
771
+
772
+
773
+ def test_hash_password_with_salt():
774
+ """Test that hash_password produces salted hashes."""
775
+ password = "test_password"
776
+ hash1 = hash_password(password)
777
+ hash2 = hash_password(password)
778
+ # Hashes should be different due to random salt
779
+ assert hash1 != hash2
780
+ # Both should contain a colon separator
781
+ assert ":" in hash1
782
+ assert ":" in hash2
783
+
784
+
785
+ def test_verify_password():
786
+ """Test that verify_password correctly validates passwords."""
787
+ password = "my_secret"
788
+ stored_hash = hash_password(password)
789
+ assert verify_password(password, stored_hash) is True
790
+ assert verify_password("wrong_password", stored_hash) is False
791
+ # Test backward compatibility with old unsalted hashes
792
+ old_hash = hashlib.sha256(password.encode()).hexdigest()
793
+ assert verify_password(password, old_hash) is True
794
+
795
+
796
+ def test_upload_path_traversal_attempt():
797
+ """Test that uploading with a path traversal filename is sanitized."""
798
+ response = client.post(
799
+ "/upload",
800
+ files={"file": ("../../etc/passwd", b"malicious content", "text/plain")},
801
+ data={"hours": "1"},
802
+ )
803
+ assert response.status_code == 200
804
+ data = response.json()
805
+ assert data["filename"] == "passwd"
806
+
807
+
808
+ def test_finalize_invalid_hours():
809
+ """Test that /upload/finalize rejects invalid expiry hours."""
810
+ initiate_response = client.post("/upload/initiate")
811
+ upload_id = initiate_response.json()["upload_id"]
812
+
813
+ client.post(
814
+ "/upload/chunk",
815
+ data={"upload_id": upload_id, "chunk_number": "1"},
816
+ files={"file": ("chunk", b"some content")},
817
+ )
818
+
819
+ finalize_response = client.post(
820
+ "/upload/finalize",
821
+ data={
822
+ "upload_id": upload_id,
823
+ "filename": "test.txt",
824
+ "hours": "999",
825
+ },
826
+ )
827
+ assert finalize_response.status_code == 400
828
+ assert "Invalid expiry time" in finalize_response.json()["detail"]
829
+
830
+
831
+ def test_finalize_exceeds_max_size(monkeypatch):
832
+ """Test that /upload/finalize rejects files exceeding MAX_FILE_SIZE."""
833
+ monkeypatch.setattr("main.MAX_FILE_SIZE", 10)
834
+
835
+ initiate_response = client.post("/upload/initiate")
836
+ upload_id = initiate_response.json()["upload_id"]
837
+
838
+ client.post(
839
+ "/upload/chunk",
840
+ data={"upload_id": upload_id, "chunk_number": "1"},
841
+ files={"file": ("chunk", b"this is way more than ten bytes of content")},
842
+ )
843
+
844
+ finalize_response = client.post(
845
+ "/upload/finalize",
846
+ data={
847
+ "upload_id": upload_id,
848
+ "filename": "too_big.txt",
849
+ "hours": "1",
850
+ },
851
+ )
852
+ assert finalize_response.status_code == 413
853
+ assert "File too large" in finalize_response.json()["detail"]
854
+
855
+
856
+ def test_health_endpoint_timezone_aware():
857
+ """Test that /health returns a timezone-aware timestamp."""
858
+ response = client.get("/health")
859
+ assert response.status_code == 200
860
+ data = response.json()
861
+ timestamp = data["timestamp"]
862
+ # Timezone-aware ISO format contains '+' or 'Z'
863
+ assert "+" in timestamp or "Z" in timestamp or timestamp.endswith("+00:00")
864
+
865
+
866
+ def test_duplicate_preserves_one_time_flag():
867
+ """Test that uploading a duplicate file preserves the original one_time flag."""
868
+ content = b"unique content for one_time preservation test " + os.urandom(8)
869
+ response1 = client.post(
870
+ "/upload",
871
+ files={"file": ("one_time_original.txt", content, "text/plain")},
872
+ data={"hours": "1", "one_time": "true"},
873
+ )
874
+ assert response1.status_code == 200
875
+ data1 = response1.json()
876
+ assert data1["one_time"] is True
877
+
878
+ response2 = client.post(
879
+ "/upload",
880
+ files={"file": ("duplicate.txt", content, "text/plain")},
881
+ data={"hours": "1", "one_time": "false"},
882
+ )
883
+ assert response2.status_code == 200
884
+ data2 = response2.json()
885
+ assert data2["one_time"] is True
886
+ assert data1["file_id"] == data2["file_id"]
887
+
888
+
889
+ def test_extend_file_expiry():
890
+ """Test extending file expiry time via /extend/{file_id}."""
891
+ client_id = "extend-test-client"
892
+ upload_response = client.post(
893
+ "/upload",
894
+ files={"file": ("extend_test.txt", b"extend me", "text/plain")},
895
+ data={"hours": "1", "client_id": client_id},
896
+ )
897
+ assert upload_response.status_code == 200
898
+ file_id = upload_response.json()["file_id"]
899
+ original_expiry = upload_response.json()["expires_at"]
900
+
901
+ extend_response = client.post(
902
+ f"/extend/{file_id}",
903
+ json={"client_id": client_id, "hours": 24}
904
+ )
905
+ assert extend_response.status_code == 200
906
+ data = extend_response.json()
907
+ assert data["success"] is True
908
+ assert data["new_expires_at"] != original_expiry
909
+
910
+
911
+ def test_extend_file_unauthorized():
912
+ """Test that extending file expiry with wrong client_id fails."""
913
+ owner_client_id = "owner-client-extend"
914
+ upload_response = client.post(
915
+ "/upload",
916
+ files={"file": ("extend_unauth.txt", b"extend unauthorized", "text/plain")},
917
+ data={"hours": "1", "client_id": owner_client_id},
918
+ )
919
+ file_id = upload_response.json()["file_id"]
920
+
921
+ extend_response = client.post(
922
+ f"/extend/{file_id}",
923
+ json={"client_id": "wrong-client", "hours": 24}
924
+ )
925
+ assert extend_response.status_code == 403
926
+
927
+
928
+ def test_extend_file_invalid_hours():
929
+ """Test that extending with invalid hours fails."""
930
+ client_id = "extend-invalid-client"
931
+ upload_response = client.post(
932
+ "/upload",
933
+ files={"file": ("extend_invalid.txt", b"extend invalid", "text/plain")},
934
+ data={"hours": "1", "client_id": client_id},
935
+ )
936
+ file_id = upload_response.json()["file_id"]
937
+
938
+ extend_response = client.post(
939
+ f"/extend/{file_id}",
940
+ json={"client_id": client_id, "hours": 999}
941
+ )
942
+ assert extend_response.status_code == 400
943
+
944
+
945
+ def test_debug_bulk_delete_authorized():
946
+ """Test bulk delete with admin authentication."""
947
+ client_id = "bulk-delete-client"
948
+ file_ids = []
949
+
950
+ for i in range(3):
951
+ upload_response = client.post(
952
+ "/upload",
953
+ files={"file": (f"bulk_{i}.txt", f"bulk content {i}".encode(), "text/plain")},
954
+ data={"hours": "1", "client_id": client_id},
955
+ )
956
+ file_ids.append(upload_response.json()["file_id"])
957
+
958
+ bulk_response = client.post(
959
+ "/debug/bulk-delete",
960
+ auth=("admin", "changeme123"),
961
+ json={"file_ids": file_ids}
962
+ )
963
+ assert bulk_response.status_code == 200
964
+ data = bulk_response.json()
965
+ assert data["success"] is True
966
+ assert data["deleted_count"] == 3
967
+
968
+ for file_id in file_ids:
969
+ get_response = client.get(f"/{file_id}/bulk_0.txt")
970
+ assert get_response.status_code == 404
971
+
972
+
973
+ def test_debug_bulk_delete_unauthorized():
974
+ """Test that bulk delete requires admin authentication."""
975
+ response = client.post("/debug/bulk-delete", json={"file_ids": ["fake-id"]})
976
+ assert response.status_code == 401
@@ -1,159 +0,0 @@
1
-
2
- import os
3
- import shutil
4
- import tempfile
5
- import hashlib
6
-
7
- from unittest.mock import MagicMock, patch
8
- from cli.tempspace import parse_time, format_size, calculate_file_hash, zip_directory, upload_file, download_file, main
9
-
10
-
11
- # --- Unit Tests ---
12
-
13
- def test_parse_time():
14
- assert parse_time("7d") == 168
15
- assert parse_time("24h") == 24
16
- assert parse_time("360") == 360
17
- assert parse_time("1D") == 24
18
- assert parse_time("2H") == 2
19
- assert parse_time(" 7d ") == 168
20
- assert parse_time("invalid") is None
21
- assert parse_time("1w") is None
22
-
23
- def test_format_size():
24
- assert format_size(0) == "0B"
25
- assert format_size(1023) == "1023.0 B"
26
- assert format_size(100) == "100.0 B"
27
- assert format_size(1024) == "1.0 KB"
28
- assert format_size(1024 * 1024) == "1.0 MB"
29
- assert format_size(1024 * 1024 * 1024 * 2.5) == "2.5 GB"
30
-
31
- def test_calculate_file_hash():
32
- with tempfile.NamedTemporaryFile(delete=False) as f:
33
- f.write(b"hello world")
34
- filepath = f.name
35
- try:
36
- expected_hash = hashlib.sha256(b"hello world").hexdigest()
37
- assert calculate_file_hash(filepath) == expected_hash
38
- finally:
39
- os.remove(filepath)
40
-
41
- def test_zip_directory():
42
- # Create a dummy directory structure
43
- temp_dir = tempfile.mkdtemp()
44
- try:
45
- os.makedirs(os.path.join(temp_dir, "subdir"))
46
- with open(os.path.join(temp_dir, "file1.txt"), "w") as f:
47
- f.write("content1")
48
-
49
- # Call zip_directory
50
- zip_path = zip_directory(temp_dir)
51
-
52
- assert os.path.exists(zip_path)
53
- assert zip_path.endswith(".zip")
54
- assert os.path.basename(temp_dir) in os.path.basename(zip_path)
55
-
56
- # Cleanup zip
57
- os.remove(zip_path)
58
- finally:
59
- shutil.rmtree(temp_dir)
60
-
61
- # --- Mock Tests ---
62
-
63
- @patch('cli.tempspace.requests.Session')
64
- def test_upload_file_success(mock_session_cls, capsys):
65
- mock_session = mock_session_cls.return_value
66
-
67
- # Mock Initiate
68
- mock_session.post.side_effect = [
69
- MagicMock(status_code=200, json=lambda: {'upload_id': '123'}), # Initiate
70
- MagicMock(status_code=200), # Chunk 1
71
- MagicMock(status_code=200, json=lambda: {'url': 'http://test.com/file', 'hash': 'abc', 'hash_verified': True}) # Finalize
72
- ]
73
-
74
- with tempfile.NamedTemporaryFile(delete=False) as f:
75
- f.write(b"test content")
76
- filepath = f.name
77
-
78
- mock_console = MagicMock()
79
-
80
- try:
81
- upload_file(mock_console, filepath, 24, None, False, False, "http://test-server")
82
-
83
- # Assert calls
84
- assert mock_session.post.call_count == 3
85
- # Verify initiate call
86
- mock_session.post.call_args_list[0][0][0].endswith("/upload/initiate")
87
- # Verify finalize call
88
- mock_session.post.call_args_list[2][0][0].endswith("/upload/finalize")
89
-
90
- # Verify success message printed
91
- # mock_console.print.assert_called() # Hard to check partial strings on rich objects without more complex matching
92
-
93
- finally:
94
- os.remove(filepath)
95
-
96
- @patch('cli.tempspace.requests.Session')
97
- def test_download_file_success(mock_session_cls):
98
- mock_session = mock_session_cls.return_value
99
-
100
- # Mock HEAD request (optional/handled in code?) Code does checks but uses same session
101
- mock_session.head.return_value = MagicMock(status_code=200)
102
-
103
- # Mock GET request
104
- content = b"downloaded content"
105
- mock_response = MagicMock(status_code=200)
106
- mock_response.headers = {'content-length': str(len(content)), 'content-disposition': 'attachment; filename="server_file.txt"'}
107
- mock_response.iter_content.return_value = [content]
108
- mock_session.get.return_value = mock_response
109
-
110
- mock_console = MagicMock()
111
-
112
- # Run download
113
- try:
114
- download_file(mock_console, "http://test-server/file")
115
-
116
- # Verify file saved
117
- assert os.path.exists("server_file.txt")
118
- with open("server_file.txt", "rb") as f:
119
- assert f.read() == content
120
- finally:
121
- if os.path.exists("server_file.txt"):
122
- os.remove("server_file.txt")
123
-
124
-
125
- @patch('sys.argv', ['tempspace', 'file.txt'])
126
- @patch('cli.tempspace.upload_file')
127
- def test_main_upload_defaults(mock_upload):
128
- # Mock os.path.isfile via patch is tricky if imported in module.
129
- # cli.tempspace uses os.path.isfile directly from os module import?
130
- # It does `import os`. So `os.path.isfile`.
131
- with patch('cli.tempspace.os.path.isfile', return_value=True):
132
- main()
133
-
134
- mock_upload.assert_called_once()
135
- # args: (console, filepath, hours, password, one_time, qr, url)
136
- args = mock_upload.call_args[0]
137
- assert args[1] == 'file.txt'
138
- assert args[2] == 24 # Default 24h
139
-
140
- @patch('sys.argv', ['tempspace', 'file.txt', '-t', '1h', '-p', 'secret', '--qr'])
141
- @patch('cli.tempspace.upload_file')
142
- def test_main_upload_custom(mock_upload):
143
- with patch('cli.tempspace.os.path.isfile', return_value=True):
144
- main()
145
-
146
- args = mock_upload.call_args[0]
147
- assert args[1] == 'file.txt'
148
- assert args[2] == 1
149
- assert args[3] == 'secret'
150
- assert args[5] is True
151
-
152
- @patch('sys.argv', ['tempspace', 'download', 'http://example.com/file', '-p', 'pass'])
153
- @patch('cli.tempspace.download_file')
154
- def test_main_download_command(mock_download):
155
- main()
156
- mock_download.assert_called_once()
157
- args = mock_download.call_args[0]
158
- assert args[1] == 'http://example.com/file'
159
- assert args[2] == 'pass'
File without changes
File without changes