tempspace-cli 1.2.9__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.9
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,9 +4,15 @@ import os
4
4
  import sys
5
5
  import hashlib
6
6
  import multiprocessing
7
-
8
7
  import math
9
- import time
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
+
10
16
  from rich.console import Console
11
17
  from rich.panel import Panel
12
18
  from rich.table import Table
@@ -14,16 +20,62 @@ from rich import box
14
20
  import qrcode
15
21
  from rich.prompt import Prompt, Confirm
16
22
  from rich.progress import Progress, BarColumn, TextColumn, TransferSpeedColumn, TimeRemainingColumn
17
-
18
23
  from rich.live import Live
19
- import shutil
20
- import tempfile
21
24
  from requests.adapters import HTTPAdapter
22
25
  from urllib3.util.retry import Retry
23
26
 
24
- # Default configuration
25
27
  DEFAULT_SERVER_URL = "https://tempspace.needrp.net"
26
- 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)
27
79
 
28
80
  def parse_time(time_str: str) -> int:
29
81
  """Parses a user-provided time string into a total number of hours.
@@ -55,6 +107,11 @@ def parse_time(time_str: str) -> int:
55
107
  except ValueError:
56
108
  return None
57
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
+
58
115
  def format_size(size_bytes: int) -> str:
59
116
  """Converts a file size in bytes into a human-readable string.
60
117
 
@@ -92,89 +149,64 @@ def calculate_file_hash(filepath: str) -> str:
92
149
 
93
150
 
94
151
 
95
- def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]):
96
- """Creates a requests Session with automatic retries."""
152
+ def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504]):
153
+ """Creates a requests Session with automatic retries and default timeout."""
97
154
  session = requests.Session()
98
155
  retry = Retry(
99
156
  total=retries,
100
157
  read=retries,
101
158
  connect=retries,
102
- other=retries,
103
159
  backoff_factor=backoff_factor,
104
160
  status_forcelist=status_forcelist,
105
- allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS"]
106
161
  )
107
162
  adapter = HTTPAdapter(max_retries=retry)
108
163
  session.mount('http://', adapter)
109
164
  session.mount('https://', adapter)
165
+ session.request_timeout = REQUEST_TIMEOUT
110
166
  return session
111
167
 
112
168
 
113
- def zip_directory(path: str) -> str:
114
- """Zips a directory and returns the path to the temporary zip file."""
115
- # 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."""
116
171
  temp_dir = tempfile.mkdtemp()
117
- # Use the original folder name for the zip file
118
172
  base_name = os.path.basename(os.path.normpath(path))
119
173
  archive_base = os.path.join(temp_dir, base_name)
120
-
121
174
  shutil.make_archive(archive_base, 'zip', path)
122
- return archive_base + ".zip"
175
+ return archive_base + ".zip", temp_dir
123
176
 
124
177
 
125
178
  def download_file(console, url, password=None):
126
179
  """Downloads a file from Tempspace."""
127
180
  session = get_retry_session()
128
-
181
+
129
182
  try:
130
- # Check if it's a valid Tempspace URL and extract ID logic if needed,
131
- # but for now we'll assume the URL is the full download link or similar.
132
- # Actually, the user provides the "Download Link" which usually leads to a landing page.
133
- # The CLI needs to handle the actual file download.
134
- # If the URL is http://site/DOWLOAD_ID, the direct download might be different.
135
- # Let's assume the user passes the direct download link or we try to download from it.
136
- # However, Tempspace likely has a landing page.
137
- # If the user gives the landing page URL, we might need to scrape or use an API.
138
- # Given I don't see the server code, I'll assume the user might provide a direct file URL
139
- # or the tool should try to GET the URL.
140
- # If the server has an API like /api/download/<id>?password=..., that would be best.
141
- # Looking at upload_file response: `download_link = data.get('url')`.
142
- # Code audit earlier showed `DEFAULT_SERVER_URL`.
143
- # Use regex to extract ID from URL if possible, or just try GET.
144
-
145
- # Simple approach: GET the URL. If it initiates a download (headers), good.
146
-
147
- session.head(url, allow_redirects=True)
148
- # Check headers for content-disposition or just proceed
149
-
150
- if password:
151
- # If password is needed, headers or query param might be required.
152
- # Since I don't know the exact server auth mechanism for downloads,
153
- # I will try passing it as a query param 'password' or header 'X-Password'.
154
- # Better: ask the user to enter it if the server responds 401/403.
155
- pass
156
-
157
- # For a streaming download
158
- 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
+
159
192
  response.raise_for_status()
160
-
193
+
161
194
  total_size = int(response.headers.get('content-length', 0))
162
195
  disposition = response.headers.get('content-disposition')
163
- # Try to get filename from Content-Disposition
196
+
164
197
  filename = None
165
198
  if disposition:
166
- import re
167
- # specific regex for filename="example.txt" or filename=example.txt
168
- fname = re.findall(r'filename=["\']?([^"\';]+)["\']?', disposition)
169
- if fname:
170
- filename = fname[0]
171
-
172
- # 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
+
173
206
  if not filename:
174
- from urllib.parse import unquote
175
- parsed_path = unquote(url.split("?")[0])
176
- filename = parsed_path.split("/")[-1]
177
-
207
+ parsed_path = unquote(url.split("?")[0])
208
+ filename = parsed_path.split("/")[-1]
209
+
178
210
  if not filename:
179
211
  filename = "downloaded_file"
180
212
 
@@ -185,21 +217,25 @@ def download_file(console, url, password=None):
185
217
  TransferSpeedColumn(), "•",
186
218
  TimeRemainingColumn(),
187
219
  )
188
-
220
+
189
221
  with Live(Panel(progress, title="[cyan]Downloading[/cyan]", border_style="cyan", title_align="left")):
190
222
  task_id = progress.add_task(filename, total=total_size)
191
223
  with open(filename, 'wb') as f:
192
224
  for chunk in response.iter_content(CHUNK_SIZE):
193
225
  f.write(chunk)
194
226
  progress.update(task_id, advance=len(chunk))
195
-
227
+
196
228
  console.print(Panel(f"[bold green]Download successful![/] Saved to '{filename}'", border_style="green"))
197
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"))
198
234
  except Exception as e:
199
235
  console.print(Panel(f"[bold red]Download failed:[/] {e}", border_style="red"))
200
236
 
201
237
 
202
- 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):
203
239
  """Handles the upload of a single file.
204
240
 
205
241
  Args:
@@ -210,13 +246,20 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
210
246
  one_time (bool): Whether the file should be a one-time download.
211
247
  qr (bool): Whether to display a QR code for the download link.
212
248
  url (str): The base URL of the Tempspace server.
249
+ client_id (str): Optional client ID for file ownership tracking.
213
250
  """
214
- # --- Validate Inputs ---
215
251
  if not os.path.isfile(filepath):
216
252
  console.print(Panel(f"[bold red]Error:[/] File not found at '{filepath}'", title="[bold red]Error[/bold red]", border_style="red"))
217
- 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
218
262
 
219
- # --- Display File Details ---
220
263
  table = Table(title="File Details", show_header=False, box=box.ROUNDED, border_style="cyan")
221
264
  table.add_column("Field", style="bold")
222
265
  table.add_column("Value")
@@ -226,27 +269,21 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
226
269
  table.add_row("Password", "[green]Yes[/green]" if password else "[red]No[/red]")
227
270
  table.add_row("One-Time Download", "[green]Yes[/green]" if one_time else "[red]No[/red]")
228
271
 
229
- # --- Prepare Upload ---
230
272
  upload_url = f"{url.rstrip('/')}"
231
273
  filename = os.path.basename(filepath)
232
274
  file_size = os.path.getsize(filepath)
233
275
 
234
- # --- Calculate Hash ---
235
276
  client_hash = calculate_file_hash(filepath)
236
277
  table.add_row("File Hash", f"[cyan]{client_hash}[/cyan]")
237
278
  console.print(table)
238
279
 
239
-
240
- # --- Chunked Upload ---
241
280
  response = None
242
281
  session = get_retry_session()
243
282
  try:
244
- # 1. Initiate Upload
245
- initiate_response = session.post(f"{upload_url}/upload/initiate")
283
+ initiate_response = session.post(f"{upload_url}/upload/initiate", timeout=REQUEST_TIMEOUT)
246
284
  initiate_response.raise_for_status()
247
285
  upload_id = initiate_response.json()['upload_id']
248
286
 
249
- # 2. Upload Chunks
250
287
  progress = Progress(
251
288
  TextColumn("[bold blue]{task.description}", justify="right"),
252
289
  BarColumn(bar_width=None),
@@ -267,45 +304,40 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
267
304
  }
268
305
  files = {'file': (f'chunk_{chunk_number}', chunk, 'application/octet-stream')}
269
306
 
270
- # Manual retry for individual chunks to provide user feedback
271
- max_chunk_retries = 3
272
- for attempt in range(max_chunk_retries):
273
- try:
274
- chunk_response = session.post(
275
- f"{upload_url}/upload/chunk",
276
- data=chunk_data,
277
- files=files,
278
- timeout=30
279
- )
280
- chunk_response.raise_for_status()
281
- progress.update(task_id, advance=len(chunk))
282
- break
283
- except (requests.exceptions.RequestException, ConnectionError) as e:
284
- if attempt < max_chunk_retries - 1:
285
- # Briefly wait before retrying the chunk
286
- time.sleep(1 * (attempt + 1))
287
- continue
288
- else:
289
- raise e
290
-
291
- # 3. Finalize Upload
307
+ chunk_response = session.post(
308
+ f"{upload_url}/upload/chunk",
309
+ data=chunk_data,
310
+ files=files,
311
+ timeout=REQUEST_TIMEOUT
312
+ )
313
+ chunk_response.raise_for_status()
314
+ progress.update(task_id, advance=len(chunk))
315
+
292
316
  console.print(Panel("[bold green]Finalizing upload...[/bold green]", border_style="green"))
293
317
  finalize_data = {
294
318
  'upload_id': upload_id,
295
319
  'filename': filename,
296
320
  'hours': str(hours),
297
321
  'one_time': str(one_time).lower(),
298
- 'client_hash': client_hash, # Send the client-side hash for verification
322
+ 'client_hash': client_hash,
299
323
  }
324
+ if client_id:
325
+ finalize_data['client_id'] = client_id
300
326
  if password:
301
327
  finalize_data['password'] = password
302
328
 
303
- 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)
304
330
  response.raise_for_status()
305
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
306
338
  except FileNotFoundError:
307
- console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.", title="[bold red]Error[/bold red]", border_style="red"))
308
- return
339
+ console.print(Panel(f"[bold red]Error:[/] The file '{filepath}' was not found.", border_style="red"))
340
+ return False
309
341
  except requests.exceptions.RequestException as e:
310
342
  error_message = str(e)
311
343
  if e.response:
@@ -313,13 +345,12 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
313
345
  error_message = e.response.json().get('detail', e.response.text)
314
346
  except Exception:
315
347
  error_message = e.response.text
316
- console.print(Panel(f"[bold red]An error occurred:[/] {error_message}", title="[bold red]Error[/bold red]", border_style="red"))
317
- return
348
+ console.print(Panel(f"[bold red]An error occurred:[/] {error_message}", border_style="red"))
349
+ return False
318
350
  except Exception as e:
319
- console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}", title="[bold red]Error[/bold red]", border_style="red"))
320
- return
351
+ console.print(Panel(f"[bold red]An unexpected error occurred:[/] {e}", border_style="red"))
352
+ return False
321
353
 
322
- # --- Handle Response ---
323
354
  if response is not None:
324
355
  if response.status_code == 200:
325
356
  try:
@@ -343,8 +374,17 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
343
374
  qr_code.make(fit=True)
344
375
  qr_code.print_ascii()
345
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
+
346
387
  except requests.exceptions.JSONDecodeError:
347
- # Fallback for older servers or unexpected plain text responses
348
388
  download_link = response.text.strip()
349
389
  success_panel = Panel(f"[bold green]Upload successful![/bold green]\n\nDownload Link: {download_link}",
350
390
  title="[bold cyan]Success[/bold cyan]", border_style="green")
@@ -355,13 +395,43 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
355
395
  qr_code.add_data(download_link)
356
396
  qr_code.make(fit=True)
357
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
358
408
  else:
359
409
  try:
360
410
  error_details = response.json()
361
411
  error_message = error_details.get('detail', 'No details provided.')
362
412
  except requests.exceptions.JSONDecodeError:
363
413
  error_message = response.text
364
- 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
365
435
 
366
436
 
367
437
  def main():
@@ -372,22 +442,52 @@ def main():
372
442
  """
373
443
  console = Console()
374
444
 
375
- # --- Handle Download Command ---
376
445
  if len(sys.argv) > 1 and sys.argv[1] == 'download':
377
446
  parser = argparse.ArgumentParser(description="Download a file from Tempspace.")
378
447
  parser.add_argument("url", help="The URL of the file to download.")
379
448
  parser.add_argument("-p", "--password", help="The password for the file.")
380
-
381
- # We process only the download arguments
449
+
382
450
  if len(sys.argv) == 2 and (sys.argv[1] == '-h' or sys.argv[1] == '--help'):
383
451
  parser.print_help()
384
452
  sys.exit(0)
385
-
453
+
386
454
  args = parser.parse_args(sys.argv[2:])
387
455
  download_file(console, args.url, args.password)
388
456
  return
389
457
 
390
- # --- 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
+
391
491
  console.print(Panel("[bold cyan]Tempspace File Uploader[/bold cyan]", expand=False, border_style="blue"))
392
492
 
393
493
  parser = argparse.ArgumentParser(
@@ -407,9 +507,7 @@ def main():
407
507
 
408
508
  filepaths = args.filepaths
409
509
 
410
- # --- Interactive Mode ---
411
510
  if args.it:
412
- # Interactive mode only supports a single file, so we overwrite the filepaths list
413
511
  filepath = Prompt.ask("Enter the path to the file you want to upload")
414
512
  filepaths = [filepath]
415
513
  args.time = Prompt.ask("Set the file's expiration time (e.g., '24h', '7d')", default='24')
@@ -417,7 +515,6 @@ def main():
417
515
  args.one_time = Confirm.ask("Delete the file after the first download?", default=False)
418
516
  args.qr = Confirm.ask("Display a QR code of the download link?", default=False)
419
517
 
420
- # --- Validate Inputs ---
421
518
  if not filepaths:
422
519
  console.print(Panel("[bold red]Error:[/] No file path(s) provided.", title="[bold red]Error[/bold red]", border_style="red"))
423
520
  parser.print_help()
@@ -428,24 +525,41 @@ def main():
428
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"))
429
526
  sys.exit(1)
430
527
 
431
- # --- Process each file ---
432
- # --- 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
+
433
539
  for i, filepath in enumerate(filepaths):
434
540
  actual_path = filepath
435
541
  is_temp = False
436
-
542
+ temp_dir = None
543
+
437
544
  if os.path.isdir(filepath):
438
545
  console.print(f"[bold yellow]Zipping directory '{os.path.basename(filepath)}'...[/]")
439
- actual_path = zip_directory(filepath)
546
+ actual_path, temp_dir = zip_directory(filepath)
440
547
  is_temp = True
441
548
 
442
549
  if len(filepaths) > 1:
443
550
  console.print(f"\n[bold yellow]Uploading file {i+1} of {len(filepaths)}: {os.path.basename(actual_path)}[/bold yellow]\n")
444
-
445
- upload_file(console, actual_path, hours, args.password, args.one_time, args.qr, args.url)
446
-
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
+
447
556
  if is_temp:
448
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)
449
563
 
450
564
 
451
565
  if __name__ == "__main__":
@@ -7,7 +7,7 @@ packages = ["cli"]
7
7
 
8
8
  [project]
9
9
  name = "tempspace-cli"
10
- version = "1.2.9"
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.9
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