Repository: google/turbinia (language: python)

Issue:
Add requirements.txt

Implement an API endpoint to upload evidence to shared file storage
Implement an API endpoint that allows a user to upload an evidence file for processing.

It would be useful to be able to have a way to upload files to be processed with Turbinia, however, this endpoint probably shouldn't be used for very large files (e.g. a 500 GB raw disk image). So the implementation should handle normal size plaso files or compressed directories. We can do some testing around what file sizes would be reasonable.
Implemented TurbiniaEvidence object in Redis
### Description of the change
Implemented TurbiniaEvidence object in Redis. The task manager will write new evidence to Redis whenever a new one is added. This will save the evidence as a hash of json-dumped attributes in Redis. I created functions in the state manager to write, update, query and summarize the TurbiniaEvidence objects in Redis. Moreover, I updated the setattr method in Evidence to automatically update the evidence in Redis whenever an attribute is changed, and added the attribute 'id' to track the evidence, which is also displayed in the web API. I also added attributes to both the evidence and tasks classes to correlate them with each other by their respective ID's. I also added unit tests to test the new state manager functions. 

### Applicable issues
Added tests for RedisStateManager in state_manager_test.py (issue #22 ).

### Checklist
- [X] All tests were successful.
- [X] Unit tests added.


Discussion / hints:
This was done by #64 



Fix patch:
diff --git a/requirements.txt b/requirements.txt
index c5d21c8d9..cdea30ef5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,6 +24,7 @@ psq
 pyparsing<3
 pyyaml>=5.4.1
 pyOpenSSL
+python-multipart
 redis<4.2
 six>=1.15.0
 urllib3[secure]
diff --git a/turbinia/api/cli/README.rst b/turbinia/api/cli/README.rst
index 27b3499f4..a002947e5 100644
--- a/turbinia/api/cli/README.rst
+++ b/turbinia/api/cli/README.rst
@@ -105,11 +105,12 @@ You can view the help menu with the ``-h`` argument
      -h, --help                  Show this message and exit.
 
    Commands:
-     config  Get Turbinia configuration.
-     jobs    Get a list of enabled Turbinia jobs.
-     result  Get Turbinia request or task results.
-     status  Get Turbinia request or task status.
-     submit  Submit new requests to the Turbinia API server.
+     config    Get Turbinia configuration.
+     evidence  Get or upload Turbinia evidence.
+     jobs      Get a list of enabled Turbinia jobs.
+     result    Get Turbinia request or task results.
+     status    Get Turbinia request or task status.
+     submit    Submit new requests to the Turbinia API server.
 
 Getting the server configuration
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/turbinia/api/cli/requirements.txt b/turbinia/api/cli/requirements.txt
index 82ea01760..02e75977a 100644
--- a/turbinia/api/cli/requirements.txt
+++ b/turbinia/api/cli/requirements.txt
@@ -1,3 +1,4 @@
 click
 turbinia-api-lib
 google-auth-oauthlib
+pandas
\ No newline at end of file
diff --git a/turbinia/api/cli/setup.py b/turbinia/api/cli/setup.py
index cababac2d..ca6b2ed31 100644
--- a/turbinia/api/cli/setup.py
+++ b/turbinia/api/cli/setup.py
@@ -6,21 +6,21 @@
 from setuptools import setup, find_packages
 from pathlib import Path
 
-NAME = "turbinia-client"
-VERSION = "1.0.3"
+NAME = 'turbinia-client'
+VERSION = '1.0.3'
 
-REQUIRES = ["click", "turbinia-api-lib", 'google-auth-oauthlib']
+REQUIRES = ['click', 'turbinia-api-lib', 'google-auth-oauthlib', 'pandas']
 
 this_directory = Path(__file__).parent
-README = (this_directory / "README.rst").read_text()
+README = (this_directory / 'README.rst').read_text()
 
 setup(
     name=NAME, version=VERSION,
-    description="Turbinia API Client command-line tool.",
-    long_description_content_type="text/markdown",
+    description='Turbinia API Client command-line tool.',
+    long_description_content_type='text/markdown',
     long_description=README, keywords=[
-        "Turbinia Client", "Turbinia", "Turbinia API Server"
-    ], python_requires=">=3.6", install_requires=REQUIRES,
+        'Turbinia Client', 'Turbinia', 'Turbinia API Server'
+    ], python_requires='>=3.6', install_requires=REQUIRES,
     packages=find_packages(), include_package_data=True, entry_points={
         'console_scripts': [
             'turbinia-client=turbinia_client.turbiniacli_tool:main'
diff --git a/turbinia/api/cli/turbinia_client/core/commands.py b/turbinia/api/cli/turbinia_client/core/commands.py
index 486107b51..8c3c90205 100644
--- a/turbinia/api/cli/turbinia_client/core/commands.py
+++ b/turbinia/api/cli/turbinia_client/core/commands.py
@@ -18,6 +18,7 @@
 import logging
 import click
 import base64
+import mimetypes
 import tarfile
 
 from turbinia_api_lib import exceptions
@@ -27,6 +28,7 @@
 from turbinia_api_lib.api import turbinia_configuration_api
 from turbinia_api_lib.api import turbinia_jobs_api
 from turbinia_api_lib.api import turbinia_request_results_api
+from turbinia_api_lib.api import turbinia_evidence_api
 
 from turbinia_client.core import groups
 from turbinia_client.helpers import formatter
@@ -361,3 +363,162 @@ def create_request(ctx: click.Context, *args: int, **kwargs: int) -> None:
         f'when calling create_request: {exception.body}')
   except (TypeError, exceptions.ApiTypeError) as exception:
     log.error(f'The request object is invalid. {exception}')
+
+
+@groups.evidence_group.command('summary')
+@click.pass_context
+@click.option(
+    '--group', '-g', help='Attribute by which output will be grouped.',
+    default=None, required=False)
+@click.option(
+    '--output', '-o', help='Type of output (keys | content | count).',
+    default='keys', required=False)
+@click.option(
+    '--json_dump', '-j', help='Generates JSON output.', is_flag=True,
+    required=False)
+def get_evidence_summary(
+    ctx: click.Context, group: str, output: str, json_dump: bool) -> None:
+  """Gets Turbinia evidence summary."""
+  client: api_client.ApiClient = ctx.obj.api_client
+  api_instance = turbinia_evidence_api.TurbiniaEvidenceApi(client)
+  try:
+    api_response = api_instance.get_evidence_summary(group, output)
+    if json_dump:
+      formatter.echo_json(api_response)
+    else:
+      report = formatter.EvidenceSummaryMarkdownReport(
+          api_response).generate_summary_markdown(output)
+      click.echo(report)
+  except exceptions.ApiException as exception:
+    log.error(
+        f'Received status code {exception.status} '
+        f'when calling get_evidence_summary: {exception.body}')
+
+
+@groups.evidence_group.command('query')
+@click.pass_context
+@click.argument('attribute_name')
+@click.argument('attribute_value')
+@click.option(
+    '--output', '-o', help='Type of output (keys | content | count).',
+    default='keys', is_flag=False, required=False)
+@click.option(
+    '--json_dump', '-j', help='Generates JSON output.', is_flag=True,
+    required=False)
+def query_evidence(
+    ctx: click.Context, attribute_name: str, attribute_value: str, output: str,
+    json_dump: bool) -> None:
+  """Queries Turbinia evidence."""
+  client: api_client.ApiClient = ctx.obj.api_client
+  api_instance = turbinia_evidence_api.TurbiniaEvidenceApi(client)
+  try:
+    api_response = api_instance.query_evidence(
+        attribute_value, attribute_name, output)
+    if json_dump:
+      formatter.echo_json(api_response)
+    else:
+      report = formatter.EvidenceSummaryMarkdownReport(
+          api_response).generate_summary_markdown(output)
+      click.echo(report)
+  except exceptions.ApiException as exception:
+    log.error(
+        f'Received status code {exception.status} '
+        f'when calling get_task_status: {exception.body}')
+
+
+@groups.evidence_group.command('get')
+@click.pass_context
+@click.argument('evidence_id')
+@click.option(
+    '--show_all', '-a', help='Shows all evidence attributes.', is_flag=True,
+    required=False)
+@click.option(
+    '--json_dump', '-j', help='Generates JSON output.', is_flag=True,
+    required=False)
+def get_evidence(
+    ctx: click.Context, evidence_id: str, show_all: bool,
+    json_dump: bool) -> None:
+  """Get Turbinia evidence."""
+  client: api_client.ApiClient = ctx.obj.api_client
+  api_instance = turbinia_evidence_api.TurbiniaEvidenceApi(client)
+  try:
+    api_response = api_instance.get_evidence_by_id(evidence_id)
+    if json_dump:
+      formatter.echo_json(api_response)
+    else:
+      report = formatter.EvidenceMarkdownReport(api_response).generate_markdown(
+          1, show_all=show_all)
+      click.echo(report)
+  except exceptions.ApiException as exception:
+    log.error(
+        f'Received status code {exception.status} '
+        f'when calling get_evidence: {exception.body}')
+
+
+@groups.evidence_group.command('upload')
+@click.pass_context
+@click.argument('ticket_id')
+@click.option(
+    '--path', '-p', help='Path of file or directory to be uploaded.',
+    required=True, multiple=True)
+@click.option(
+    '--calculate_hash', '-c', help='Calculates file hash.', is_flag=True,
+    required=False)
+@click.option(
+    '--json_dump', '-j', help='Generates JSON output.', is_flag=True,
+    required=False)
+def upload_evidence(
+    ctx: click.Context, ticket_id: str, path: list, calculate_hash: bool,
+    json_dump: bool) -> None:
+  """Uploads evidence to Turbinia server."""
+  client: api_client.ApiClient = ctx.obj.api_client
+  api_instance_config = turbinia_configuration_api.TurbiniaConfigurationApi(
+      client)
+  max_upload_size = api_instance_config.read_config()['API_MAX_UPLOAD_SIZE']
+  api_instance = turbinia_evidence_api.TurbiniaEvidenceApi(client)
+  files = []
+  for current_path in path:
+    if os.path.isfile(current_path):
+      files.append(current_path)
+      continue
+    for file_name in os.listdir(current_path):
+      file_path = os.path.join(current_path, file_name)
+      if os.path.isfile(file_path):
+        files.append(file_path)
+  report = {}
+  for file_path in files:
+    try:
+      size = os.path.getsize(file_path)
+      if size > max_upload_size:
+        error_message = (
+            f'Unable to upload {size / (1024 ** 3)} GB file',
+            f'{file_path} greater than {max_upload_size / (1024 ** 3)} GB')
+        log.error(error_message)
+        continue
+      abs_path = os.path.abspath(file_path)
+      with open(file_path, 'rb') as f:
+        filename = os.path.basename(f.name)
+        filedata = f.read()
+        mimetype = (
+            mimetypes.guess_type(filename)[0] or 'application/octet-stream')
+        upload_file = tuple([filename, filedata, mimetype])
+    except OSError:
+      log.error(f'Unable to read file in {file_path}')
+      continue
+    try:
+      api_response = api_instance.upload_evidence(
+          upload_file, ticket_id, calculate_hash)
+      report[abs_path] = api_response
+    except exceptions.ApiException as exception:
+      error_message = (
+          f'Received status code {exception.status} '
+          f'when calling upload_evidence: {exception}')
+      log.error(error_message)
+      report[abs_path] = error_message
+  if json_dump:
+    formatter.echo_json(report)
+  else:
+    report = '\n'.join(
+        formatter.EvidenceMarkdownReport({}).dict_to_markdown(
+            report, 0, format_keys=False))
+    click.echo(report)
diff --git a/turbinia/api/cli/turbinia_client/core/groups.py b/turbinia/api/cli/turbinia_client/core/groups.py
index 9e400805b..d5a88fe0c 100644
--- a/turbinia/api/cli/turbinia_client/core/groups.py
+++ b/turbinia/api/cli/turbinia_client/core/groups.py
@@ -22,6 +22,11 @@ def config_group():
   """Get Turbinia configuration."""
 
 
+@click.group('evidence')
+def evidence_group():
+  """Get or upload Turbinia evidence."""
+
+
 @click.group('status')
 def status_group():
   """Get Turbinia request or task status."""
diff --git a/turbinia/api/cli/turbinia_client/helpers/formatter.py b/turbinia/api/cli/turbinia_client/helpers/formatter.py
index 7f35005fc..293f60a97 100644
--- a/turbinia/api/cli/turbinia_client/helpers/formatter.py
+++ b/turbinia/api/cli/turbinia_client/helpers/formatter.py
@@ -30,10 +30,15 @@
 
 log = logging.getLogger('turbinia')
 
+IMPORTANT_ATTRIBUTES = {
+    'id', '_name', 'type', 'size', 'request_id', 'tasks', 'source_path',
+    'local_path', 'creation_time', 'last_update'
+}
+
 
 def echo_json(json_data: dict) -> None:
   """Pretty print JSON data."""
-  if isinstance(json_data, dict):
+  if isinstance(json_data, (dict, list, int)):
     click_echo(json.dumps(json_data, indent=2))
 
 
@@ -82,6 +87,18 @@ def bold(self, text):
     """
     return f'**{text.strip():s}**'
 
+  def heading(self, text: str, number: int) -> str:
+    """Formats text as heading in Markdown format.
+
+    Args:
+        text(string): Text to format
+        number(int): Heading number
+
+    Return:
+        string: Formatted text.
+    """
+    return f'{"#"*number} {text.strip():s}'
+
   def heading1(self, text):
     """Formats text as heading 1 in Markdown format.
 
@@ -91,7 +108,7 @@ def heading1(self, text):
     Return:
         string: Formatted text.
     """
-    return f'# {text.strip():s}'
+    return self.heading(text, 1)
 
   def heading2(self, text):
     """Formats text as heading 2 in Markdown format.
@@ -102,7 +119,7 @@ def heading2(self, text):
     Return:
         string: Formatted text.
     """
-    return f'## {text.strip():s}'
+    return self.heading(text, 2)
 
   def heading3(self, text):
     """Formats text as heading 3 in Markdown format.
@@ -113,7 +130,7 @@ def heading3(self, text):
     Return:
         string: Formatted text.
     """
-    return f'### {text.strip():s}'
+    return self.heading(text, 3)
 
   def heading4(self, text):
     """Formats text as heading 4 in Markdown format.
@@ -124,7 +141,7 @@ def heading4(self, text):
     Return:
         string: Formatted text.
     """
-    return f'#### {text.strip():s}'
+    return self.heading(text, 4)
 
   def heading5(self, text):
     """Formats text as heading 5 in Markdown format.
@@ -133,7 +150,7 @@ def heading5(self, text):
      Return:
         string: Formatted text.
     """
-    return f'##### {text.strip():s}'
+    return self.heading(text, 5)
 
   def bullet(self, text, level=1):
     """Formats text as a bullet in Markdown format.
@@ -175,6 +192,72 @@ def remove(self, component: MarkdownReportComponent) -> None:
     """
     pass
 
+  def list_to_markdown(self, original_list: list, level: int = 1) -> list:
+    """Generates a Markdown based on a python list.
+
+      Args:
+        original_list(list): List to format
+        level(int): Indentation level.
+
+      Returns:
+        report (list): List of Markdown lines.
+    """
+    if not original_list:
+      return [self.bullet('[EMPTY LIST]', level)]
+    report = []
+    for item in original_list:
+      if isinstance(item, dict):
+        report.extend(self.dict_to_markdown(item, level + 1))
+      elif isinstance(item, list):
+        report.extend(self.list_to_markdown(item, level + 1))
+      else:
+        if level == 0:
+          report.append(self.heading1(item))
+        else:
+          report.append(self.bullet(item, level))
+    return report
+
+  def dict_to_markdown(
+      self, original_dict: dict, level: int = 1, excluded_keys: list = (),
+      format_keys: bool = True) -> list:
+    """Generates a Markdown based on a python dictionary.
+
+      Args:
+        original_dict(dict): Dict to format.
+        level(int): Indentation level.
+        excluded_keys (list): List of dict keys to be ignored.
+        format_keys (bool): Capitalizes and removes underscores from dict keys
+          if True
+
+      Returns:
+        report (list): List of Markdown lines.
+    """
+    if not original_dict:
+      return [self.bullet('[EMPTY DICTIONARY]', level)]
+    report: list[str] = []
+    for key, value in original_dict.items():
+      if key in excluded_keys:
+        continue
+      name = key.replace('_', ' ').title() if format_keys else key
+      if isinstance(value, dict):
+        if level == 0:
+          report.append(self.heading1(f'{name}:'))
+        else:
+          report.append(self.bullet(f'{name}:', level))
+        report.extend(self.dict_to_markdown(value, level + 1))
+      elif isinstance(value, list):
+        if level == 0:
+          report.append(self.heading1(f'{name}:'))
+        else:
+          report.append(self.bullet(f'{name}:', level))
+        report.extend(self.list_to_markdown(value, level + 1))
+      else:
+        if level == 0:
+          report.append(self.heading1(f'{name}: {value}'))
+        else:
+          report.append(self.bullet(f'{name}: {value}', level))
+    return report
+
   @abstractmethod
   def generate_markdown(self) -> str:
     pass
@@ -454,3 +537,124 @@ def generate_csv(self) -> str:
       csv(str): CSV version of task statistics.
     """
     return self.generate_data_frame().to_csv(index=False)
+
+
+class EvidenceMarkdownReport(MarkdownReportComponent):
+  """Turbinia Evidence Markdown report."""
+
+  def __init__(self, evidence_data: dict):
+    """Initializes an EvidenceMarkdownReport object."""
+    super().__init__()
+    self._evidence_data: dict = evidence_data
+
+  def generate_markdown(self, level=1, show_all=False) -> str:
+    """Generates a Markdown version of Evidence.
+
+      Args:
+        level (int): Indentation level.
+        show_all (bool): Shows all evidence attributes if True.
+
+      Returns:
+        report (str): Markdown report.
+    """
+    report: list[str] = []
+    evidence_dict: dict = self._evidence_data
+    if not evidence_dict:
+      return ''
+    try:
+      report.append(
+          self.heading(
+              f"Evidence ID: {evidence_dict.get('id', 'null')}", level))
+      report.append(
+          self.bullet(
+              f"Evidence Name: {evidence_dict.get('_name', 'null')}", level))
+      report.append(
+          self.bullet(
+              f"Evidence Type: {evidence_dict.get('type', 'null')}", level))
+      report.append(
+          self.bullet(
+              f"Evidence Size: {evidence_dict.get('size', 'null')}", level))
+      report.append(
+          self.bullet(
+              f"Request ID: {evidence_dict.get('request_id', 'null')}", level))
+      report.append(self.bullet('Tasks:', level))
+      report.extend(
+          self.list_to_markdown(evidence_dict.get('tasks'), level + 1))
+      report.append(
+          self.bullet(
+              f"Source Path: {evidence_dict.get('source_path', 'null')}",
+              level))
+      report.append(
+          self.bullet(
+              f"Local Path: {evidence_dict.get('local_path', 'null')}", level))
+      report.append(
+          self.bullet(
+              f"Creation Time: {evidence_dict.get('creation_time', 'null')}",
+              level))
+      report.append(
+          self.bullet(
+              f"Last Update: {evidence_dict.get('last_update', 'null')}",
+              level))
+
+      if show_all:
+        report.extend(
+            self.dict_to_markdown(
+                evidence_dict, level=level, excluded_keys=IMPORTANT_ATTRIBUTES))
+
+      report.append('')
+
+    except TypeError as exception:
+      log.warning(f'Error formatting the Markdown report: {exception!s}')
+
+    self.report = '\n'.join(report)
+    return self.report
+
+
+class EvidenceSummaryMarkdownReport(EvidenceMarkdownReport):
+  """Turbinia Evidence Markdown report."""
+
+  def __init__(self, summary: dict | list | int):
+    """Initializes an EvidenceSummaryMarkdownReport object."""
+    super().__init__({})
+    self._summary = summary
+
+  def generate_content_markdown(self, summary, level=1):
+    """Generates the content Markdown summary.
+
+      Args:
+        summary (bool): Evidence summary.
+        level (int): Indentation level.
+
+      Returns:
+        report (list): List with Markdown lines.
+    """
+    report = []
+    for item in summary:
+      self._evidence_data = item
+      report.append(self.generate_markdown(level=(level + 1)))
+    return report
+
+  def generate_summary_markdown(self, output: str = 'keys') -> str:
+    """Generates the evidence summary Markdown.
+
+      Args:
+        output (str):Type of output (keys | content | count).
+
+      Returns:
+        report (str): Markdown report.
+    """
+    if output == 'content':
+      if isinstance(self._summary, dict):
+        report = []
+        for attribute, summary in self._summary.items():
+          report.append(self.bullet(f'{attribute}:'))
+          report.extend(self.generate_content_markdown(summary, level=2))
+        return '\n'.join(report)
+      return '\n'.join(self.generate_content_markdown(self._summary))
+    elif isinstance(self._summary, list):
+      return '\n'.join(self.list_to_markdown(self._summary, level=0))
+    elif isinstance(self._summary, dict):
+      return '\n'.join(
+          self.dict_to_markdown(self._summary, level=0, format_keys=False))
+    elif isinstance(self._summary, int):
+      return self.heading1(f'{self._summary} evidences found')
\ No newline at end of file
diff --git a/turbinia/api/cli/turbinia_client/turbiniacli.py b/turbinia/api/cli/turbinia_client/turbiniacli.py
index bd80d2ddf..695e4f4f4 100644
--- a/turbinia/api/cli/turbinia_client/turbiniacli.py
+++ b/turbinia/api/cli/turbinia_client/turbiniacli.py
@@ -22,6 +22,7 @@
 from typing import Union
 
 from turbinia_api_lib.api import turbinia_configuration_api
+from turbinia_api_lib.api import turbinia_evidence_api
 
 from turbinia_client.helpers import auth_helper
 
@@ -163,11 +164,10 @@ def default_config(self, host: str) -> turbinia_api_lib.Configuration:
 
   def get_evidence_arguments(self, evidence_name=None) -> dict:
     """Gets arguments for Evidence types."""
-    api_instance = turbinia_configuration_api.TurbiniaConfigurationApi(
-        self.api_client)
+    api_instance = turbinia_evidence_api.TurbiniaEvidenceApi(self.api_client)
     api_response = None
     if evidence_name:
-      api_response = api_instance.get_evidence_attributes_by_name(evidence_name)
+      api_response = api_instance.get_evidence_attributes(evidence_name)
     else:
       api_response = api_instance.get_evidence_types()
 
diff --git a/turbinia/api/cli/turbinia_client/turbiniacli_tool.py b/turbinia/api/cli/turbinia_client/turbiniacli_tool.py
index 8bee66c8f..082aeaf61 100644
--- a/turbinia/api/cli/turbinia_client/turbiniacli_tool.py
+++ b/turbinia/api/cli/turbinia_client/turbiniacli_tool.py
@@ -92,6 +92,7 @@ def main():
 
   # Register all command groups.
   cli.add_command(groups.submit_group)
+  cli.add_command(groups.evidence_group)
   cli.add_command(groups.config_group)
   cli.add_command(groups.jobs_group)
   cli.add_command(groups.result_group)
diff --git a/turbinia/api/openapi.yaml b/turbinia/api/openapi.yaml
index 1b5c55bf0..126b922bd 100644
--- a/turbinia/api/openapi.yaml
+++ b/turbinia/api/openapi.yaml
@@ -18,53 +18,84 @@ components:
             - items:
                 type: string
               type: array
+            - type: "null"
           title: Filter Patterns
         group_id:
           anyOf:
             - type: string
+            - type: "null"
           title: Group Id
         jobs_allowlist:
           anyOf:
             - items:
                 type: string
               type: array
+            - type: "null"
           title: Jobs Allowlist
         jobs_denylist:
           anyOf:
             - items:
                 type: string
               type: array
+            - type: "null"
           title: Jobs Denylist
         reason:
           anyOf:
             - type: string
+            - type: "null"
           title: Reason
         recipe_data:
           anyOf:
             - type: string
+            - type: "null"
           title: Recipe Data
         recipe_name:
           anyOf:
             - type: string
+            - type: "null"
           title: Recipe Name
         request_id:
           anyOf:
             - type: string
+            - type: "null"
           title: Request Id
         requester:
           anyOf:
             - type: string
+            - type: "null"
           title: Requester
         sketch_id:
           anyOf:
             - type: integer
+            - type: "null"
           title: Sketch Id
         yara_rules:
           anyOf:
             - type: string
+            - type: "null"
           title: Yara Rules
       title: BaseRequestOptions
       type: object
+    Body_upload_evidence_api_evidence_upload_post:
+      properties:
+        calculate_hash:
+          default: false
+          title: Calculate Hash
+          type: boolean
+        files:
+          items:
+            format: binary
+            type: string
+          title: Files
+          type: array
+        ticket_id:
+          title: Ticket Id
+          type: string
+      required:
+        - ticket_id
+        - files
+      title: Body_upload_evidence_api_evidence_upload_post
+      type: object
     HTTPValidationError:
       properties:
         detail:
@@ -80,7 +111,7 @@ components:
         description:
           anyOf:
             - type: string
-
+            - type: "null"
           default: Turbinia request object
           title: Description
         evidence:
@@ -121,7 +152,7 @@ info:
     url: https://www.apache.org/licenses/LICENSE-2.0.html
   title: Turbinia API Server
   version: 1.0.0
-openapi: 3.0.2
+openapi: 3.1.0
 paths:
   /api/config/:
     get:
@@ -136,7 +167,157 @@ paths:
       summary: Read Config
       tags:
         - Turbinia Configuration
-  /api/config/evidence:
+  /api/config/request_options:
+    get:
+      description: Returns a list BaseRequestOptions attributes.
+      operationId: get_request_options
+      responses:
+        "200":
+          content:
+            application/json:
+              schema: {}
+          description: Successful Response
+      summary: Get Request Options
+      tags:
+        - Turbinia Configuration
+  /api/evidence/query:
+    get:
+      description:
+        "Queries evidence in Redis that have the specified attribute value.\n\
+        \nArgs:\n  attribute_name (str): Name of attribute to be queried.\n  attribute_value\
+        \ (str): Value the attribute must have.\n  output Optional(str): Sets how\
+        \ the evidence found will be output.\n\nReturns:\n  summary (dict): Summary\
+        \ of all evidences and their content.\n\nRaises:\n  HTTPException: If no matching\
+        \ evidence is found."
+      operationId: query_evidence
+      parameters:
+        - in: query
+          name: attribute_name
+          required: false
+          schema:
+            default: request_id
+            enum:
+              - _name
+              - cloud_only
+              - context_dependent
+              - copyable
+              - creation_time
+              - description
+              - has_child_evidence
+              - last_update
+              - local_path
+              - mount_path
+              - parent_evidence
+              - request_id
+              - resource_id
+              - resource_tracked
+              - save_metadata
+              - saved_path
+              - saved_path_type
+              - size
+              - source
+              - source_path
+              - type
+              - tasks
+            title: Attribute Name
+            type: string
+        - in: query
+          name: attribute_value
+          required: true
+          schema:
+            title: Attribute Value
+            type: string
+        - in: query
+          name: output
+          required: false
+          schema:
+            default: keys
+            enum:
+              - keys
+              - content
+              - count
+            title: Output
+            type: string
+      responses:
+        "200":
+          content:
+            application/json:
+              schema: {}
+          description: Successful Response
+        "422":
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/HTTPValidationError"
+          description: Validation Error
+      summary: Query Evidence
+      tags:
+        - Turbinia Evidence
+  /api/evidence/summary:
+    get:
+      description:
+        "Retrieves a summary of all evidences in Redis.\n\nArgs:\n  group\
+        \ Optional(str): Attribute used to group summary.\n  output Optional(str):\
+        \ Sets how the evidence found will be output. \n\nReturns:\n  summary (dict):\
+        \ Summary of all evidences and their content.\n\nRaises:\n  HTTPException:\
+        \ if there are no evidences."
+      operationId: get_evidence_summary
+      parameters:
+        - in: query
+          name: group
+          required: false
+          schema:
+            enum:
+              - _name
+              - cloud_only
+              - context_dependent
+              - copyable
+              - creation_time
+              - description
+              - has_child_evidence
+              - last_update
+              - local_path
+              - mount_path
+              - parent_evidence
+              - request_id
+              - resource_id
+              - resource_tracked
+              - save_metadata
+              - saved_path
+              - saved_path_type
+              - size
+              - source
+              - source_path
+              - type
+            title: Group
+            type: string
+        - in: query
+          name: output
+          required: false
+          schema:
+            default: keys
+            enum:
+              - keys
+              - content
+              - count
+            title: Output
+            type: string
+      responses:
+        "200":
+          content:
+            application/json:
+              schema: {}
+          description: Successful Response
+        "422":
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/HTTPValidationError"
+          description: Validation Error
+      summary: Get Evidence Summary
+      tags:
+        - Turbinia Evidence
+  /api/evidence/types:
     get:
       description: Returns supported Evidence object types and required parameters.
       operationId: get_evidence_types
@@ -148,17 +329,19 @@ paths:
           description: Successful Response
       summary: Get Evidence Types
       tags:
-        - Turbinia Configuration
-  /api/config/evidence/{evidence_name}:
+        - Turbinia Evidence
+  /api/evidence/types/{evidence_type}:
     get:
-      description: Returns supported Evidence object types and required parameters.
-      operationId: get_evidence_attributes_by_name
+      description:
+        "Returns supported required parameters for evidence type.\n\nArgs:\n\
+        \  evidence_type (str): Name of evidence type."
+      operationId: get_evidence_attributes
       parameters:
         - in: path
-          name: evidence_name
+          name: evidence_type
           required: true
           schema:
-            title: Evidence Name
+            title: Evidence Type
       responses:
         "200":
           content:
@@ -171,22 +354,69 @@ paths:
               schema:
                 $ref: "#/components/schemas/HTTPValidationError"
           description: Validation Error
-      summary: Get Evidence Attributes By Name
+      summary: Get Evidence Attributes
       tags:
-        - Turbinia Configuration
-  /api/config/request_options:
+        - Turbinia Evidence
+  /api/evidence/upload:
+    post:
+      description:
+        "Upload evidence file to server for processing.\n\nArgs:\n  ticket_id\
+        \ (str): ID of the ticket, which will be the name of the folder \n    where\
+        \ the evidence will be saved.\n  calculate_hash (bool): Boolean defining if\
+        \ the hash of the evidence should\n    be calculated.\n  file (List[UploadFile]):\
+        \ Evidence files to be uploaded to folder for later\n      processing. The\
+        \ maximum size of the file is 10 GB. \n\nReturns:\n  List of uploaded evidences\
+        \ or warning messages if any."
+      operationId: upload_evidence
+      requestBody:
+        content:
+          multipart/form-data:
+            schema:
+              $ref: "#/components/schemas/Body_upload_evidence_api_evidence_upload_post"
+        required: true
+      responses:
+        "200":
+          content:
+            application/json:
+              schema: {}
+          description: Successful Response
+        "422":
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/HTTPValidationError"
+          description: Validation Error
+      summary: Upload Evidence
+      tags:
+        - Turbinia Evidence
+  /api/evidence/{evidence_id}:
     get:
-      description: Returns a list BaseRequestOptions attributes.
-      operationId: get_request_options
+      description:
+        "Retrieves an evidence in Redis by using its UUID.\n\nArgs:\n \
+        \ evidence_id (str): The UUID of the evidence.\n\nRaises:\n  HTTPException:\
+        \ if the evidence is not found.\n\nReturns:\n  Dictionary of the stored evidence"
+      operationId: get_evidence_by_id
+      parameters:
+        - in: path
+          name: evidence_id
+          required: true
+          schema:
+            title: Evidence Id
       responses:
         "200":
           content:
             application/json:
               schema: {}
           description: Successful Response
-      summary: Get Request Options
+        "422":
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/HTTPValidationError"
+          description: Validation Error
+      summary: Get Evidence By Id
       tags:
-        - Turbinia Configuration
+        - Turbinia Evidence
   /api/jobs/:
     get:
       description: Return enabled jobs from the current Turbinia config.
diff --git a/turbinia/api/routes/config.py b/turbinia/api/routes/config.py
index 9d1ebd3d0..aec1f9686 100644
--- a/turbinia/api/routes/config.py
+++ b/turbinia/api/routes/config.py
@@ -22,7 +22,6 @@
 from fastapi.requests import Request
 
 from turbinia import config as turbinia_config
-from turbinia import evidence
 from turbinia.api.schemas import request_options
 
 log = logging.getLogger('turbinia')
@@ -43,24 +42,6 @@ async def read_config(request: Request):
         status_code=500, detail='error reading configuration') from exception
 
 
-@router.get('/evidence')
-async def get_evidence_types(request: Request):
-  """Returns supported Evidence object types and required parameters."""
-  attribute_mapping = evidence.map_evidence_attributes()
-  return JSONResponse(content=attribute_mapping, status_code=200)
-
-
-@router.get('/evidence/{evidence_name}')
-async def get_evidence_attributes_by_name(request: Request, evidence_name):
-  """Returns supported Evidence object types and required parameters."""
-  attribute_mapping = evidence.map_evidence_attributes()
-  attribute_mapping = {evidence_name: attribute_mapping.get(evidence_name)}
-  if not attribute_mapping:
-    raise HTTPException(
-        status_code=404, detail=f'Evidence type ({evidence_name:s}) not found.')
-  return JSONResponse(content=attribute_mapping, status_code=200)
-
-
 @router.get('/request_options')
 async def get_request_options(request: Request):
   """Returns a list BaseRequestOptions attributes."""
diff --git a/turbinia/api/routes/evidence.py b/turbinia/api/routes/evidence.py
new file mode 100644
index 000000000..c79ba3d49
--- /dev/null
+++ b/turbinia/api/routes/evidence.py
@@ -0,0 +1,265 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Turbinia API - Config router"""
+
+import hashlib
+import logging
+import os
+
+from datetime import datetime
+from fastapi import HTTPException, APIRouter, UploadFile, Query, Form
+from fastapi.requests import Request
+from fastapi.responses import JSONResponse
+from typing import List, Annotated
+
+from turbinia.api.schemas import request_options
+from turbinia import evidence
+from turbinia import config as turbinia_config
+from turbinia import state_manager
+from turbinia import TurbiniaException
+
+log = logging.getLogger('turbinia')
+router = APIRouter(prefix='/evidence', tags=['Turbinia Evidence'])
+redis_manager = state_manager.RedisStateManager()
+
+EVIDENCE_SUMMARY_ATTRIBUTES = (
+    '_name', 'cloud_only', 'context_dependent', 'copyable', 'creation_time',
+    'description', 'has_child_evidence', 'last_update', 'local_path',
+    'mount_path', 'parent_evidence', 'request_id', 'resource_id',
+    'resource_tracked', 'save_metadata', 'saved_path', 'saved_path_type',
+    'size', 'source', 'source_path', 'type')
+
+EVIDENCE_QUERY_ATTRIBUTES = EVIDENCE_SUMMARY_ATTRIBUTES + ('tasks',)
+
+
+async def get_file_path(file_name: str, ticket_id: str) -> str:
+  """Gets the path where the file will be saved.
+
+  Args:
+    file_name (str): Original name of the file.
+    ticked_id (str): ID of the current ticket
+  
+  Returns:
+    file_path (str): Path where the file will be saved.
+  """
+  try:
+    file_name = os.path.splitext(file_name)[0]
+    file_extension = os.path.splitext(file_name)[1]
+    file_extension = '.' + file_extension if file_extension else ''
+    current_time = datetime.now().strftime(turbinia_config.DATETIME_FORMAT)
+    new_name = f'{file_name}_{current_time}{file_extension}'
+    os.makedirs(
+        f'{turbinia_config.API_EVIDENCE_UPLOAD_DIR}/{ticket_id}', exist_ok=True)
+    return os.path.join(
+        turbinia_config.API_EVIDENCE_UPLOAD_DIR, ticket_id, new_name)
+  except OSError as exception:
+    raise TurbiniaException(
+        f'Failed in setting path for file {file_name} in ticket '
+        f'{ticket_id}') from exception
+
+
+async def upload_file(
+    file: UploadFile, file_path: str, calculate_hash: bool = False) -> dict:
+  """Uploads file from FastAPI to server.
+
+  Args:
+    file (UploadFile): Evidence file to be uploaded to folder for later
+        processing. The maximum size of the file is set on the Turbinia
+        configuration file. 
+    file_path (str): Path where the file will be saved.
+    calculate_hash (bool): Boolean defining if the hash of the evidence should
+      be calculated.
+  
+  Raises:
+    IOError: If file is greater than the maximum size.
+  
+  Returns:
+    file_info (dict): Information about the uploaded file.
+  """
+  size = 0
+  sha_hash = hashlib.sha3_224()
+  with open(file_path, 'wb') as saved_file:
+    while (chunk := await
+           file.read(turbinia_config.API_UPLOAD_CHUNK_SIZE
+                    )) and size < turbinia_config.API_MAX_UPLOAD_SIZE:
+      saved_file.write(chunk)
+      if calculate_hash:
+        sha_hash.update(chunk)
+      size += len(chunk)
+      if size >= turbinia_config.API_MAX_UPLOAD_SIZE:
+        error_message = (
+            f'Unable to upload file {file.filename} greater',
+            f'than {turbinia_config.API_MAX_UPLOAD_SIZE / (1024 ** 3)} GB')
+        log.error(error_message)
+        raise TurbiniaException(error_message)
+    file_info = {
+        'original_name': file.filename,
+        'file_name': os.path.basename(file_path),
+        'file_path': file_path,
+        'size': size
+    }
+    if calculate_hash:
+      file_info['hash'] = sha_hash.hexdigest()
+  return file_info
+
+
+@router.get('/types')
+async def get_evidence_types(request: Request):
+  """Returns supported Evidence object types and required parameters."""
+  attribute_mapping = evidence.map_evidence_attributes()
+  return JSONResponse(content=attribute_mapping, status_code=200)
+
+
+@router.get('/types/{evidence_type}')
+async def get_evidence_attributes(request: Request, evidence_type):
+  """Returns supported required parameters for evidence type.
+  
+  Args:
+    evidence_type (str): Name of evidence type.
+  """
+  attribute_mapping = evidence.map_evidence_attributes()
+  attribute_mapping = {evidence_type: attribute_mapping.get(evidence_type)}
+  if not attribute_mapping:
+    raise HTTPException(
+        status_code=404, detail=f'Evidence type ({evidence_type:s}) not found.')
+  return JSONResponse(content=attribute_mapping, status_code=200)
+
+
+@router.get('/summary')
+async def get_evidence_summary(
+    request: Request, group: str = Query(
+        None, enum=EVIDENCE_SUMMARY_ATTRIBUTES), output: str = Query(
+            'keys', enum=('keys', 'content', 'count'))):
+  """Retrieves a summary of all evidences in Redis.
+
+  Args:
+    group Optional(str): Attribute used to group summary.
+    output Optional(str): Sets how the evidence found will be output. 
+
+  Returns:
+    summary (dict): Summary of all evidences and their content.
+  
+  Raises:
+    HTTPException: if there are no evidences.
+  """
+  if group and group not in EVIDENCE_SUMMARY_ATTRIBUTES:
+    raise HTTPException(
+        status_code=400, detail=(
+            f'Cannot group by attribute {group}. '
+            f'Groupable attributes: {EVIDENCE_SUMMARY_ATTRIBUTES}'))
+  if evidences := redis_manager.get_evidence_summary(group, output):
+    return JSONResponse(content=evidences, status_code=200)
+  raise HTTPException(status_code=404, detail='No evidence found.')
+
+
+@router.get('/query')
+async def query_evidence(
+    request: Request, attribute_name: str = Query(
+        'request_id', enum=EVIDENCE_QUERY_ATTRIBUTES),
+    attribute_value: str = Query(), output: str = Query(
+        'keys', enum=('keys', 'content', 'count'))):
+  """Queries evidence in Redis that have the specified attribute value.
+
+  Args:
+    attribute_name (str): Name of attribute to be queried.
+    attribute_value (str): Value the attribute must have.
+    output Optional(str): Sets how the evidence found will be output.
+
+  Returns:
+    summary (dict): Summary of all evidences and their content.
+  
+  Raises:
+    HTTPException: If no matching evidence is found.
+  """
+  if attribute_name and attribute_name not in EVIDENCE_QUERY_ATTRIBUTES:
+    raise HTTPException(
+        status_code=400, detail=(
+            f'Cannot query by {attribute_name}. '
+            f'Queryable attributes: {EVIDENCE_QUERY_ATTRIBUTES}'))
+  evidences_found = None
+  if attribute_value == 'hash':
+    evidences_found = redis_manager.get_evidence_key_by_hash(attribute_name)
+  else:
+    evidences_found = redis_manager.query_evidence(
+        attribute_name, attribute_value, output)
+  if evidences_found:
+    return JSONResponse(content=evidences_found, status_code=200)
+  raise HTTPException(
+      status_code=404, detail=(
+          f'No evidence found with value {attribute_value} in attribute '
+          f'{attribute_name}.'))
+
+
+@router.get('/{evidence_id}')
+async def get_evidence_by_id(request: Request, evidence_id):
+  """Retrieves an evidence in Redis by using its UUID.
+
+  Args:
+    evidence_id (str): The UUID of the evidence.
+  
+  Raises:
+    HTTPException: if the evidence is not found.
+
+  Returns:
+    Dictionary of the stored evidence
+  """
+  if stored_evidence := redis_manager.get_evidence_data(evidence_id):
+    return JSONResponse(content=stored_evidence, status_code=200)
+  raise HTTPException(
+      status_code=404,
+      detail=f'UUID {evidence_id} not found or it had no associated evidences.')
+
+
+@router.post('/upload')
+async def upload_evidence(
+    ticket_id: Annotated[str, Form()], files: List[UploadFile],
+    calculate_hash: Annotated[bool, Form()] = False):
+  """Upload evidence file to server for processing.
+
+  Args:
+    ticket_id (str): ID of the ticket, which will be the name of the folder 
+      where the evidence will be saved.
+    calculate_hash (bool): Boolean defining if the hash of the evidence should
+      be calculated.
+    file (List[UploadFile]): Evidence files to be uploaded to folder for later
+        processing. The maximum size of the file is 10 GB. 
+  
+  Returns:
+    List of uploaded evidences or warning messages if any.
+  """
+  evidences = []
+  for file in files:
+    warning_message = None
+    try:
+      file_path = await get_file_path(file.filename, ticket_id)
+      file_info = await upload_file(file, file_path, calculate_hash)
+      file.file.close()
+    except TurbiniaException as exception:
+      warning_message = exception
+    else:
+      if evidence_key := redis_manager.get_evidence_key_by_hash(
+          file_info.get('hash')):
+        warning_message = (
+            f'File {file.filename} was uploaded before, check {evidence_key}')
+    if warning_message:
+      evidences.append(warning_message)
+      log.error(warning_message)
+      try:
+        os.remove(file_path)
+      except OSError as exception:
+        log.error(f'Could not remove file {file_path}: {exception}')
+    else:
+      evidences.append(file_info)
+  return JSONResponse(content=evidences, status_code=200)
diff --git a/turbinia/api/routes/router.py b/turbinia/api/routes/router.py
index 6c63a73d2..819e08046 100644
--- a/turbinia/api/routes/router.py
+++ b/turbinia/api/routes/router.py
@@ -17,6 +17,7 @@
 from fastapi import APIRouter
 
 from turbinia.api.routes import config
+from turbinia.api.routes import evidence
 from turbinia.api.routes import jobs
 from turbinia.api.routes import logs
 from turbinia.api.routes import request
@@ -28,6 +29,7 @@
 
 # Register all API endpoints.
 api_router.include_router(config.router)
+api_router.include_router(evidence.router)
 api_router.include_router(jobs.router)
 api_router.include_router(logs.router)
 api_router.include_router(request.router)
diff --git a/turbinia/config/__init__.py b/turbinia/config/__init__.py
index 98dd2cf2a..2e4db2ffa 100644
--- a/turbinia/config/__init__.py
+++ b/turbinia/config/__init__.py
@@ -68,7 +68,10 @@
     'API_SERVER_PORT',
     'API_ALLOWED_ORIGINS',
     'API_AUTHENTICATION_ENABLED',
-    'WEBUI_PATH',
+    'API_UPLOAD_CHUNK_SIZE',
+    'API_EVIDENCE_UPLOAD_DIR',
+    'API_MAX_UPLOAD_SIZE',
+    'WEBUI_PATH'
 ]
 
 # Optional config vars.  Some may be mandatory depending on the configuration
diff --git a/turbinia/config/turbinia_config_tmpl.py b/turbinia/config/turbinia_config_tmpl.py
index 6b694707d..88d134705 100644
--- a/turbinia/config/turbinia_config_tmpl.py
+++ b/turbinia/config/turbinia_config_tmpl.py
@@ -119,6 +119,15 @@
 # check for OAuth 2.0 bearer tokens in the 'Authorization' header.
 API_AUTHENTICATION_ENABLED = False
 
+# Chunk size in bytes for chunk reading when uploading evidences to server.
+API_UPLOAD_CHUNK_SIZE = 1024
+
+# Default path to where uploaded evidence will be stored on server.
+API_EVIDENCE_UPLOAD_DIR = '/evidence'
+
+# Max size in bytes for evidence uploaded to server.
+API_MAX_UPLOAD_SIZE = 10737418240
+
 # Path to Turbinia Web UI static files
 WEBUI_PATH = '/web'
 


Test patch:
diff --git a/turbinia/api/api_server_test.py b/turbinia/api/api_server_test.py
index d76541026..27ecee652 100644
--- a/turbinia/api/api_server_test.py
+++ b/turbinia/api/api_server_test.py
@@ -18,6 +18,7 @@
 
 from collections import OrderedDict
 
+import datetime
 import unittest
 import json
 import os
@@ -81,6 +82,65 @@ class testTurbiniaAPIServer(unittest.TestCase):
       'tasks': []
   }
 
+  _EVIDENCE_TEST_DATA = {
+      'request_id': '5581344e306b42ccb965a19028d4fc58',
+      'tasks': [
+          'b73d484634164e0eb1870d101ca9ce2f',
+          'dd810119ac2443e18b69ea56c10c0a9b', 'ac4dc14080b144478437818a694e2f4d'
+      ],
+      'copyable': False,
+      'cloud_only': False,
+      'local_path': '/workspaces/turbinia/test_data/artifact_disk.dd',
+      'source_path': '/workspaces/turbinia/test_data/artifact_disk.dd',
+      'resource_tracked': False,
+      'processed_by': [],
+      'resource_id': None,
+      'credentials': [],
+      'config': {
+          'globals': {
+              'debug_tasks': False,
+              'jobs_allowlist': [],
+              'jobs_denylist': [],
+              'yara_rules': '',
+              'filter_patterns': [],
+              'sketch_id': None,
+              'group_name': '',
+              'reason': '',
+              'all_args': 'turbinia/turbiniactl.py rawdisk -l disk.dd',
+              'group_id': '55ce6e98dc154e73990b24f0c79ab07e',
+              'requester': 'root'
+          }
+      },
+      'tags': {},
+      'creation_time': '2023-08-04T19:16:28.182774Z',
+      'last_update': '2023-08-04T19:17:58.769212Z',
+      'parent_evidence': None,
+      'size': 20971520,
+      'mount_path': None,
+      'device_path': None,
+      'has_child_evidence': False,
+      'save_metadata': False,
+      'type': 'RawDisk',
+      '_name': '/workspaces/turbinia/test_data/artifact_disk.dd',
+      'context_dependent': False,
+      'state': {},
+      'id': 'b510ab6bf11a410da1fd9d9b128e7d74',
+      'hash': '4cf679344af02c2b89e4a902f939f4608bcac0fbf81511da13d7d9b9',
+      'description': None
+  }
+
+  _SORTED_KEYS_SUMMARY = {
+      '5581344e306b42ccb965a19028d4fc58': [
+          'TurbiniaEvidence:b510ab6bf11a410da1fd9d9b128e7d74'
+      ],
+      '6d6f85f44487441c9d4da1bda56ae90a': [
+          'TurbiniaEvidence:e2d9bff0c78b471e820db55080012f44',
+          'TurbiniaEvidence:0114968b6293410e818eb1ec72db56f8'
+      ]
+  }
+
+  _COUNT_SUMMARY = 3
+
   def _get_state_manager(self):
     """Gets a Redis State Manager object for test."""
     turbinia_config.STATE_MANAGER = 'Redis'
@@ -254,3 +314,147 @@ def testGetJobs(self):
     result = self.client.get('/api/jobs')
     result = json.loads(result.content)
     self.assertEqual(expected_result, result)
+
+  @mock.patch('turbinia.api.routes.evidence.redis_manager.get_evidence_data')
+  def testGetEvidence(self, testGetEvidence):
+    """Test getting Turbinia evidence."""
+    testGetEvidence.return_value = self._EVIDENCE_TEST_DATA
+    response = self.client.get(
+        f'/api/evidence/{self._EVIDENCE_TEST_DATA["id"]}')
+    result = json.loads(response.content)
+    self.assertEqual(self._EVIDENCE_TEST_DATA, result)
+
+  @mock.patch('turbinia.api.routes.evidence.redis_manager.get_evidence_data')
+  def testGetEvidenceNotFound(self, testGetEvidence):
+    """Test getting non-existent evidence."""
+    testGetEvidence.return_value = {}
+    evidence_id = '4774873a11f049233e863a009b997'
+    response = self.client.get(f'/api/evidence/{evidence_id}')
+    self.assertEqual(response.status_code, 404)
+    self.assertEqual(
+        response.json(), {
+            'detail':
+                f'UUID {evidence_id} not found or it had no associated '
+                f'evidences.'
+        })
+
+  @mock.patch('turbinia.api.routes.evidence.redis_manager.get_evidence_summary')
+  def testEvidenceSummary(self, testGetEvidenceSummary):
+    """Test getting evidence summary."""
+    testGetEvidenceSummary.return_value = self._SORTED_KEYS_SUMMARY
+    response = self.client.get(
+        '/api/evidence/summary?output=keys, group=request_id')
+    grouped_keys_result = json.loads(response.content)
+    testGetEvidenceSummary.return_value = self._COUNT_SUMMARY
+    response = self.client.get('/api/evidence/summary?output=count')
+    count_result = json.loads(response.content)
+    self.assertEqual(self._SORTED_KEYS_SUMMARY, grouped_keys_result)
+    self.assertEqual(self._COUNT_SUMMARY, count_result)
+
+  def testEvidenceSummaryWrongAttribute(self):
+    """Test getting evidence summary grouped with invalid attribute."""
+    attribute = 'test_attribute'
+    response = self.client.get(f'api/evidence/summary?group={attribute}')
+    self.assertEqual(response.status_code, 400)
+    self.assertEqual(
+        response.json()['detail'].split('.')[0],
+        f'Cannot group by attribute {attribute}')
+
+  @mock.patch('turbinia.api.routes.evidence.redis_manager.get_evidence_summary')
+  def testEvidenceSummaryNotFound(self, testGetEvidenceSummary):
+    """Test getting evidence summary with no evidence in the server."""
+    testGetEvidenceSummary.return_value = {}
+    response = self.client.get(f'/api/evidence/summary')
+    self.assertEqual(response.status_code, 404)
+    self.assertEqual(response.json(), {'detail': f'No evidence found.'})
+
+  @mock.patch('turbinia.api.routes.evidence.redis_manager.query_evidence')
+  def testEvidenceQuery(self, testQueryEvidence):
+    """Test querying evidence."""
+    request_id = '6d6f85f44487441c9d4da1bda56ae90a'
+    testQueryEvidence.return_value = self._SORTED_KEYS_SUMMARY[request_id]
+    response = self.client.get(
+        f'/api/evidence/query?attribute_name=request_id'
+        f'&attribute_value={request_id}&output=keys')
+    result = json.loads(response.content)
+    self.assertEqual(self._SORTED_KEYS_SUMMARY[request_id], result)
+
+  def testEvidenceQueryWrongAttribute(self):
+    """Test querying evidence with invalid attribute."""
+    attribute = 'test_attribute'
+    response = self.client.get(
+        f'api/evidence/query?attribute_name={attribute}&attribute_value="test"')
+    self.assertEqual(response.status_code, 400)
+    self.assertEqual(
+        response.json()['detail'].split('.')[0], f'Cannot query by {attribute}')
+
+  @mock.patch('turbinia.api.routes.evidence.redis_manager.query_evidence')
+  def testEvidenceQueryNotFound(self, testQueryEvidence):
+    """Test querying evidence with no evidence in the server."""
+    request_id = '6d6f85f44487441c9d4da1bda56ae90a'
+    testQueryEvidence.return_value = {}
+    response = self.client.get(
+        f'/api/evidence/query?attribute_name=request_id'
+        f'&attribute_value={request_id}')
+    self.assertEqual(response.status_code, 404)
+    self.assertEqual(
+        response.json(), {
+            'detail': (
+                f'No evidence found with value {request_id} in attribute '
+                f'request_id.')
+        })
+
+  @mock.patch('turbinia.api.routes.evidence.datetime')
+  @mock.patch('turbinia.api.routes.evidence.os.makedirs')
+  def testEvidenceUpload(self, mock_makedirs, mock_datetime):
+    """Tests uploading evidence."""
+    mocked_now = datetime.datetime.now()
+    mock_datetime.now.return_value = mocked_now
+    mocked_now_str = mocked_now.strftime(turbinia_config.DATETIME_FORMAT)
+
+    filedir = os.path.dirname(os.path.realpath(__file__))
+    evidence_1_name = 'wordpress_access_logs.txt'
+    evidence_2_name = 'mbr.raw'
+    evidence_1_path = os.path.join(
+        filedir, '..', '..', 'test_data', evidence_1_name)
+    evidence_2_path = os.path.join(
+        filedir, '..', '..', 'test_data', evidence_2_name)
+    ticket_id = '981234098'
+
+    expected_evidence_1_name = (
+        f'{os.path.splitext(evidence_1_name)[0]}_{mocked_now_str}')
+    expected_evidence_2_name = (
+        f'{os.path.splitext(evidence_2_name)[0]}_{mocked_now_str}')
+    expected_evidence_1_path = os.path.join(
+        turbinia_config.API_EVIDENCE_UPLOAD_DIR, ticket_id,
+        expected_evidence_1_name)
+    expected_evidence_2_path = os.path.join(
+        turbinia_config.API_EVIDENCE_UPLOAD_DIR, ticket_id,
+        expected_evidence_2_name)
+    expected_response = [{
+        'original_name': evidence_1_name,
+        'file_name': expected_evidence_1_name,
+        'file_path': expected_evidence_1_path,
+        'size': 2265,
+        'hash': '2bc7c964403ea416bf2cf9871f2385dcccdc625c46fa8d12a3f54b86'
+    }, {
+        'original_name': evidence_2_name,
+        'file_name': expected_evidence_2_name,
+        'file_path': expected_evidence_2_path,
+        'size': 4194304,
+        'hash': 'b5cfa74c1c6e1ba459c7ef68fc1bf3725d2c0a9bb63923350a13ea76'
+    }]
+
+    files = [('files', open(evidence_1_path, 'rb')),
+             ('files', open(evidence_2_path, 'rb'))]
+    with mock.patch('turbinia.api.routes.evidence.open',
+                    mock.mock_open()) as mocked_file:
+      response = self.client.post(
+          '/api/evidence/upload', files=files, data={
+              'ticket_id': ticket_id,
+              'calculate_hash': True
+          })
+      mocked_file.assert_called()
+      mocked_file.assert_called_with(expected_evidence_2_path, 'wb')
+    self.assertEqual(response.status_code, 200)
+    self.assertEqual(json.loads(response.content), expected_response)