web service api

This commit is contained in:
Morten V. Christiansen 2026-03-04 10:34:06 +01:00
parent f24b8820b6
commit c6d4ce1906
44 changed files with 697 additions and 19 deletions

78
, Normal file
View File

@ -0,0 +1,78 @@
# Importing Alpine with node 18 docker image
FROM node:20-alpine
# Add dependencies
RUN apk add git python3 make g++
# Installing restroom (all packages except sawroom)
RUN npx -y create-restroom@next restroom-mw --all --no-@restroom-mw/sawroom --no-@restroom-mw/planetmint
WORKDIR /restroom-mw
# Force old Express behavior (Express 4) so routes like "/api/*" work
RUN yarn remove express || true \
&& yarn add --exact express@4.21.2 \
&& rm -rf node_modules \
&& yarn install --force
# Configure restroom
# Set OPENAPI=false if you want to deactivate Swagger for production
ENV CUSTOM_404_MESSAGE="nothing to see here"
ENV HTTP_PORT=3300
ENV HTTPS_PORT=3301
ENV OPENAPI=true
ENV FILES_DIR=./contracts
ENV CHAIN_EXT=chain
ENV YML_EXT=yml
# Adding the exported files
RUN echo "Adding exported contracts from apiroom"
RUN echo -e "\nScenario 'ecdh': Create the keypair from a name passed from data/keys\n\n# Here we load the identity of the executor\nGiven my name is in a 'string' named 'myName'\n\n# Here we generate and print the keypair\nWhen I create the ecdh key\nThen print my 'keyring'\n"> ./contracts/Generate-a-keypair,-reading-identity-from-data.zen || true
RUN echo -e ""{\"myName\":\"User123456\"}""> ./contracts/Generate-a-keypair,-reading-identity-from-data.data
RUN echo -e "\nScenario 'ecdh': Encrypt a message with the password \nGiven that I have a 'string' named 'password' \nGiven that I have a 'string' named 'header' \nGiven that I have a 'string' named 'message' \nWhen I encrypt the secret message 'message' with 'password' \nThen print the 'secret message'\n"> ./contracts/Encrypt-a-message-with-the-password.zen || true
RUN echo -e ""{}""> ./contracts/Encrypt-a-message-with-the-password.keys
RUN echo -e ""{\"header\":\"A very important secret\",\"message\":\"Dear Bob, your name is too short, goodbye - Alice.\",\"password\":\"myVerySecretPassword\"}""> ./contracts/Encrypt-a-message-with-the-password.data
RUN echo -e "\nScenario 'ecdh': Decrypt the message with the password \nGiven that I have a valid 'secret message' \nGiven that I have a 'string' named 'password' \nWhen I decrypt the text of 'secret message' with 'password' \nWhen I rename the 'text' to 'textDecrypted' \nThen print the 'textDecrypted' as 'string'\n"> ./contracts/Decrypt-the-message-with-the-password.zen || true
RUN echo -e ""{}""> ./contracts/Decrypt-the-message-with-the-password.keys
RUN echo -e ""{\"secret_message\":{\"checksum\":\"76U+nWVZBwBMbOOktCnZug==\",\"header\":\"QSB2ZXJ5IGltcG9ydGFudCBzZWNyZXQ=\",\"iv\":\"R+B2z2pTLkMVGFCuFHnYL5sAIeuolYmgUOdMm2AOvTI=\",\"text\":\"Df8C8Kkd+ngVAi/tGUe905VPTwId4hv+iL31dgylkDaDumI3BpRO5bN1qKfSsBi2KOA=\"},\"password\":\"myVerySecretPassword\"}""> ./contracts/Decrypt-the-message-with-the-password.data
RUN echo -e "\nScenario 'ecdh': Alice encrypts a message for Bob \n\nGiven that I am known as 'sender' \nGiven that I have my valid 'keyring' \nGiven that I have a valid 'public key' from 'reciever' \nGiven that I have a 'string' named 'message' \nGiven that I have a 'string' named 'header' \n\nWhen I encrypt the secret message of 'message' for 'reciever' \nWhen I rename the 'secret message' to 'secret' \n\nThen print the 'secret' \n\n"> ./contracts/Encrypt-a-message-for-two-recipients-using-asymmetric-cryptography.zen || true
RUN echo -e ""{\"reciever\":{\"public_key\":\"BBA0kD35T9lUHR/WhDwBmgg/vMzlu1Vb0qtBjBZ8rbhdtW3AcX6z64a59RqF6FCV5q3lpiFNTmOgA264x1cZHE0=\"},\"message\":\"Dear Bob and Carl, if you are reading this, then we are not friend anymore. Goodbye.\",\"header\":\"Secret message for Bob and Carl\",\"sender\":{\"keyring\":{\"ecdh\":\"IStvfSREogWWYLB+DtpaSFqGJYMZMKvLIdGNN/H5DH4=\"}}}""> ./contracts/Encrypt-a-message-for-two-recipients-using-asymmetric-cryptography.data
RUN echo -e "\nScenario 'ecdh': Bob decrypts the message from Alice \nGiven that I am known as 'reciever' \nGiven I have my 'keyring' \nGiven I have a 'public key' from 'sender' \nGiven I have a 'secret message' named 'secret' \nWhen I decrypt the text of 'secret' from 'sender' \nThen print the 'text' as 'string' \nThen print the 'header' from 'secret' as 'string'\n"> ./contracts/Decrypt-a-message-for-two-recipients-using-asymmetric-cryptography.zen || true
RUN echo -e ""{\"sender\":{\"public_key\":\"BNRzlJ4csYlWgycGGiK/wgoEw3OizCdx9MWg06rxUBTP5rP9qPASOW5KY8YgmNjW5k7lLpboboHrsApWsvgkMN4=\"},\"secret\":{\"checksum\":\"sxoO1vewQmL8skCmfeiFgw==\",\"header\":\"U2VjcmV0IG1lc3NhZ2UgZm9yIEJvYiBhbmQgQ2FybA==\",\"iv\":\"AngaB+wTbAKWFDayWE2yWVSDD1f/w+lI+LkV0B8tIyM=\",\"text\":\"S2+pJNXhLgT46/ztk/XAJOWdl3jWR4svI170Me38bWHmvS3+kqZxkW2GIZJiw4C4GkdJ8MM2lvQJcP/GWM/7k+mc/XQoxI86Yu4RgCPqYJ+sKD0=\"},\"reciever\":{\"keyring\":{\"ecdh\":\"psBF05iHz/X8WBpwitJoSsZ7BiKawrdaVfQN3AtTa6I=\"}}}""> ./contracts/Decrypt-a-message-for-two-recipients-using-asymmetric-cryptography.data
RUN echo -e "\nrule check version 3.0.0 \nScenario 'ecdh': Bob verifies the signature from Alice \n\n\n# Here we load the pubkey we'll verify the signature against\nGiven I have a 'public key' from 'signer' \n\n# Here we load the objects to be verified\nGiven I have a 'string' named 'myMessage' \n\n# Here we load the objects's signatures\nGiven I have a 'signature' named 'myMessage.signature' \n\n# Here we perform the verifications\nWhen I verify the 'myMessage' has a ecdh signature in 'myMessage.signature' by 'signer' \n\n# Here we print out the result: if the verifications succeeded, a string will be printed out\n# if the verifications failed, Zenroom will throw an error\nThen print the string 'Zenroom certifies that signature is correct!' \nThen print the 'myMessage' \n"> ./contracts/Verify-asymmetric-cryptography-signature.zen || true
RUN echo -e ""{\"myMessage\":\"Dear Bob, your name is too short, goodbye - Alice.\",\"myMessage.signature\":{\"r\":\"vWerszPubruWexUib69c7IU8Dxy1iisUmMGC7h7arDw=\",\"s\":\"nSjxT+JAP56HMRJjrLwwB6kP+mluYySeZcG8JPBGcpY=\"},\"signer\":{\"public_key\":\"BBCQg21VcjsmfTmNsg+I+8m1Cm0neaYONTqRnXUjsJLPa8075IYH+a9w2wRO7rFM1cKmv19Igd7ntDZcUvLq3xI=\"}}""> ./contracts/Verify-asymmetric-cryptography-signature.data
RUN echo -e "\nScenario 'ecdh': create the signature of an object \nGiven I am 'signer' \nGiven I have my 'keyring' \nGiven that I have a 'string' named 'myMessage' inside 'mySecretStuff' \n\n# Here we are creating 3 signatures and renaming them afterwards, once with a string,\n# once with an array and once with a complex object such as the keypair\n# a signature is a schema containing two base64 key-values: 'r' and 's', read more about ECDSA at \n# https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm\n\nWhen I create the ecdh signature of 'myMessage' \nWhen I rename the 'ecdh signature' to 'myMessage.signature' \n\n# Here we are printing out the signatures \n\nThen print the 'myMessage' \nThen print the 'myMessage.signature' \n\n"> ./contracts/Sign-objects-using-asymmetric-cryptography.zen || true
RUN echo -e ""{\"mySecretStuff\":{\"myMessage\":\"Dear Bob, your name is too short, goodbye - Alice.\"},\"signer\":{\"keyring\":{\"ecdh\":\"mukeqwntoJPtAN94jgahUA/ID7NptMLNL84sMPJ++eY=\"}}}""> ./contracts/Sign-objects-using-asymmetric-cryptography.data
RUN echo -e "\n\n# Loading scenarios\nScenario 'ecdh': Create the public key\n\n# Loading the private keys\nGiven I have the 'keyring'\n\n# Generating the public keys\nWhen I create the ecdh public key\n\n\n# Here we pring all the output\nThen print the 'ecdh public key'\n\n"> ./contracts/Generate-public-key.zen || true
RUN echo -e ""{\"keyring\":{\"ecdh\":\"tWJ3bc7SgFQmWghl2lLmitzSCtfFYws1P2x8UW0edhE=\"}}""> ./contracts/Generate-public-key.data
# yarn install and run
CMD yarn start

78
Dockerfile Normal file
View File

@ -0,0 +1,78 @@
# Importing Alpine with node 18 docker image
FROM node:20-alpine
# Add dependencies
RUN apk add git python3 make g++
# Installing restroom (all packages except sawroom)
RUN npx -y create-restroom@next restroom-mw --all --no-@restroom-mw/sawroom --no-@restroom-mw/planetmint
WORKDIR /restroom-mw
# Force old Express behavior (Express 4) so routes like "/api/*" work
RUN yarn remove express || true \
&& yarn add --exact express@4.21.2 \
&& rm -rf node_modules \
&& yarn install --force
# Configure restroom
# Set OPENAPI=false if you want to deactivate Swagger for production
ENV CUSTOM_404_MESSAGE="nothing to see here"
ENV HTTP_PORT=3300
ENV HTTPS_PORT=3301
ENV OPENAPI=true
ENV FILES_DIR=./contracts
ENV CHAIN_EXT=chain
ENV YML_EXT=yml
# Adding the exported files
RUN echo "Adding exported contracts from apiroom"
RUN echo -e "\nScenario 'ecdh': Create the keypair from a name passed from data/keys\n\n# Here we load the identity of the executor\nGiven my name is in a 'string' named 'myName'\n\n# Here we generate and print the keypair\nWhen I create the ecdh key\nThen print my 'keyring'\n"> ./contracts/Generate-a-keypair,-reading-identity-from-data.zen || true
RUN echo -e ""{\"myName\":\"User123456\"}""> ./contracts/Generate-a-keypair,-reading-identity-from-data.data
RUN echo -e "\nScenario 'ecdh': Encrypt a message with the password \nGiven that I have a 'string' named 'password' \nGiven that I have a 'string' named 'header' \nGiven that I have a 'string' named 'message' \nWhen I encrypt the secret message 'message' with 'password' \nThen print the 'secret message'\n"> ./contracts/Encrypt-a-message-with-the-password.zen || true
RUN echo -e ""{}""> ./contracts/Encrypt-a-message-with-the-password.keys
RUN echo -e ""{\"header\":\"A very important secret\",\"message\":\"Dear Bob, your name is too short, goodbye - Alice.\",\"password\":\"myVerySecretPassword\"}""> ./contracts/Encrypt-a-message-with-the-password.data
RUN echo -e "\nScenario 'ecdh': Decrypt the message with the password \nGiven that I have a valid 'secret message' \nGiven that I have a 'string' named 'password' \nWhen I decrypt the text of 'secret message' with 'password' \nWhen I rename the 'text' to 'textDecrypted' \nThen print the 'textDecrypted' as 'string'\n"> ./contracts/Decrypt-the-message-with-the-password.zen || true
RUN echo -e ""{}""> ./contracts/Decrypt-the-message-with-the-password.keys
RUN echo -e ""{\"secret_message\":{\"checksum\":\"76U+nWVZBwBMbOOktCnZug==\",\"header\":\"QSB2ZXJ5IGltcG9ydGFudCBzZWNyZXQ=\",\"iv\":\"R+B2z2pTLkMVGFCuFHnYL5sAIeuolYmgUOdMm2AOvTI=\",\"text\":\"Df8C8Kkd+ngVAi/tGUe905VPTwId4hv+iL31dgylkDaDumI3BpRO5bN1qKfSsBi2KOA=\"},\"password\":\"myVerySecretPassword\"}""> ./contracts/Decrypt-the-message-with-the-password.data
RUN echo -e "\nScenario 'ecdh': Alice encrypts a message for Bob \n\nGiven that I am known as 'sender' \nGiven that I have my valid 'keyring' \nGiven that I have a valid 'public key' from 'reciever' \nGiven that I have a 'string' named 'message' \nGiven that I have a 'string' named 'header' \n\nWhen I encrypt the secret message of 'message' for 'reciever' \nWhen I rename the 'secret message' to 'secret' \n\nThen print the 'secret' \n\n"> ./contracts/Encrypt-a-message-for-two-recipients-using-asymmetric-cryptography.zen || true
RUN echo -e ""{\"reciever\":{\"public_key\":\"BBA0kD35T9lUHR/WhDwBmgg/vMzlu1Vb0qtBjBZ8rbhdtW3AcX6z64a59RqF6FCV5q3lpiFNTmOgA264x1cZHE0=\"},\"message\":\"Dear Bob and Carl, if you are reading this, then we are not friend anymore. Goodbye.\",\"header\":\"Secret message for Bob and Carl\",\"sender\":{\"keyring\":{\"ecdh\":\"IStvfSREogWWYLB+DtpaSFqGJYMZMKvLIdGNN/H5DH4=\"}}}""> ./contracts/Encrypt-a-message-for-two-recipients-using-asymmetric-cryptography.data
RUN echo -e "\nScenario 'ecdh': Bob decrypts the message from Alice \nGiven that I am known as 'reciever' \nGiven I have my 'keyring' \nGiven I have a 'public key' from 'sender' \nGiven I have a 'secret message' named 'secret' \nWhen I decrypt the text of 'secret' from 'sender' \nThen print the 'text' as 'string' \nThen print the 'header' from 'secret' as 'string'\n"> ./contracts/Decrypt-a-message-for-two-recipients-using-asymmetric-cryptography.zen || true
RUN echo -e ""{\"sender\":{\"public_key\":\"BNRzlJ4csYlWgycGGiK/wgoEw3OizCdx9MWg06rxUBTP5rP9qPASOW5KY8YgmNjW5k7lLpboboHrsApWsvgkMN4=\"},\"secret\":{\"checksum\":\"sxoO1vewQmL8skCmfeiFgw==\",\"header\":\"U2VjcmV0IG1lc3NhZ2UgZm9yIEJvYiBhbmQgQ2FybA==\",\"iv\":\"AngaB+wTbAKWFDayWE2yWVSDD1f/w+lI+LkV0B8tIyM=\",\"text\":\"S2+pJNXhLgT46/ztk/XAJOWdl3jWR4svI170Me38bWHmvS3+kqZxkW2GIZJiw4C4GkdJ8MM2lvQJcP/GWM/7k+mc/XQoxI86Yu4RgCPqYJ+sKD0=\"},\"reciever\":{\"keyring\":{\"ecdh\":\"psBF05iHz/X8WBpwitJoSsZ7BiKawrdaVfQN3AtTa6I=\"}}}""> ./contracts/Decrypt-a-message-for-two-recipients-using-asymmetric-cryptography.data
RUN echo -e "\nrule check version 3.0.0 \nScenario 'ecdh': Bob verifies the signature from Alice \n\n\n# Here we load the pubkey we'll verify the signature against\nGiven I have a 'public key' from 'signer' \n\n# Here we load the objects to be verified\nGiven I have a 'string' named 'myMessage' \n\n# Here we load the objects's signatures\nGiven I have a 'signature' named 'myMessage.signature' \n\n# Here we perform the verifications\nWhen I verify the 'myMessage' has a ecdh signature in 'myMessage.signature' by 'signer' \n\n# Here we print out the result: if the verifications succeeded, a string will be printed out\n# if the verifications failed, Zenroom will throw an error\nThen print the string 'Zenroom certifies that signature is correct!' \nThen print the 'myMessage' \n"> ./contracts/Verify-asymmetric-cryptography-signature.zen || true
RUN echo -e ""{\"myMessage\":\"Dear Bob, your name is too short, goodbye - Alice.\",\"myMessage.signature\":{\"r\":\"vWerszPubruWexUib69c7IU8Dxy1iisUmMGC7h7arDw=\",\"s\":\"nSjxT+JAP56HMRJjrLwwB6kP+mluYySeZcG8JPBGcpY=\"},\"signer\":{\"public_key\":\"BBCQg21VcjsmfTmNsg+I+8m1Cm0neaYONTqRnXUjsJLPa8075IYH+a9w2wRO7rFM1cKmv19Igd7ntDZcUvLq3xI=\"}}""> ./contracts/Verify-asymmetric-cryptography-signature.data
RUN echo -e "\nScenario 'ecdh': create the signature of an object \nGiven I am 'signer' \nGiven I have my 'keyring' \nGiven that I have a 'string' named 'myMessage' inside 'mySecretStuff' \n\n# Here we are creating 3 signatures and renaming them afterwards, once with a string,\n# once with an array and once with a complex object such as the keypair\n# a signature is a schema containing two base64 key-values: 'r' and 's', read more about ECDSA at \n# https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm\n\nWhen I create the ecdh signature of 'myMessage' \nWhen I rename the 'ecdh signature' to 'myMessage.signature' \n\n# Here we are printing out the signatures \n\nThen print the 'myMessage' \nThen print the 'myMessage.signature' \n\n"> ./contracts/Sign-objects-using-asymmetric-cryptography.zen || true
RUN echo -e ""{\"mySecretStuff\":{\"myMessage\":\"Dear Bob, your name is too short, goodbye - Alice.\"},\"signer\":{\"keyring\":{\"ecdh\":\"mukeqwntoJPtAN94jgahUA/ID7NptMLNL84sMPJ++eY=\"}}}""> ./contracts/Sign-objects-using-asymmetric-cryptography.data
RUN echo -e "\n\n# Loading scenarios\nScenario 'ecdh': Create the public key\n\n# Loading the private keys\nGiven I have the 'keyring'\n\n# Generating the public keys\nWhen I create the ecdh public key\n\n\n# Here we pring all the output\nThen print the 'ecdh public key'\n\n"> ./contracts/Generate-public-key.zen || true
RUN echo -e ""{\"keyring\":{\"ecdh\":\"tWJ3bc7SgFQmWghl2lLmitzSCtfFYws1P2x8UW0edhE=\"}}""> ./contracts/Generate-public-key.data
# yarn install and run
CMD yarn start

0
ca_api/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

213
ca_api/app.py Normal file
View File

@ -0,0 +1,213 @@
# ca_api/app.py
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from ca_api.db import db_cursor
from ca_core import entity as entity_core
from ca_core import group_member as group_core
from ca_core import property as property_core
from ca_core import metadata as metadata_core
app = FastAPI(title="PKI CA API")
def bad_request(e: Exception) -> HTTPException:
return HTTPException(status_code=400, detail=str(e))
class CreatorIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
public_key: str = Field(min_length=1, max_length=300)
class EnrollPersonIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
public_key: str = Field(min_length=1, max_length=300)
creator_id: int
class CreateGroupIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
public_key: str = Field(min_length=1, max_length=300)
creator_id: int
ca_reference: str = Field(min_length=1, max_length=100)
class AddMemberIn(BaseModel):
group_id: int
member_id: int
role: str = Field(min_length=1, max_length=10)
class SetPropertyIn(BaseModel):
entity_id: int
property_name: str = Field(min_length=1, max_length=100)
validation_policy: Optional[str] = Field(default="default", max_length=19)
source: Optional[str] = Field(default=None, max_length=150)
class DeletePropertyIn(BaseModel):
entity_id: int
property_name: str = Field(min_length=1, max_length=100)
class SetMetadataNameIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
class SetMetadataDefensePIn(BaseModel):
defense_p: bool
@app.get("/health")
def health() -> Dict[str, Any]:
return {"ok": True}
# ---- Entities ----
@app.post("/creators", status_code=201)
def create_creator(payload: CreatorIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
creator_id = entity_core.insert_creator(cur, payload.name, payload.public_key)
return {"id": creator_id}
except (ValueError, TypeError) as e:
raise bad_request(e)
@app.post("/persons", status_code=201)
def enroll_person(payload: EnrollPersonIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
person_id = entity_core.enroll_person(cur, payload.name, payload.public_key, payload.creator_id)
return {"id": person_id}
except (ValueError, TypeError) as e:
raise bad_request(e)
@app.post("/groups", status_code=201)
def create_group(payload: CreateGroupIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
group_id = entity_core.create_group(
cur,
payload.name,
payload.public_key,
payload.creator_id,
payload.ca_reference,
)
return {"id": group_id}
except (ValueError, TypeError) as e:
raise bad_request(e)
@app.get("/entities/{entity_id}")
def get_entity(entity_id: int) -> Dict[str, Any]:
with db_cursor() as cur:
row = entity_core.get_entity(cur, entity_id)
if row is None:
raise HTTPException(status_code=404, detail="Entity not found")
return dict(row)
@app.post("/entities/{entity_id}/status")
def set_entity_status(entity_id: int, status: str, changed_by: int) -> Dict[str, Any]:
try:
with db_cursor() as cur:
entity_core.set_entity_status(cur, entity_id, status, changed_by)
return {"ok": True}
except (ValueError, TypeError) as e:
raise bad_request(e)
# ---- Group members ----
@app.post("/groups/members", status_code=201)
def add_member(payload: AddMemberIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
group_core.add_group_member(cur, payload.group_id, payload.member_id, payload.role)
return {"ok": True}
except (ValueError, TypeError) as e:
raise bad_request(e)
@app.get("/groups/{group_id}/members")
def list_members(group_id: int):
with db_cursor() as cur:
rows = group_core.get_members_of_group(cur, group_id)
return [dict(r) for r in rows]
# ---- Properties ----
@app.post("/properties", status_code=201)
def set_property(payload: SetPropertyIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
property_core.set_property(
cur,
payload.entity_id,
payload.property_name,
validation_policy=payload.validation_policy or "default",
source=payload.source,
)
return {"ok": True}
except (ValueError, TypeError) as e:
raise bad_request(e)
@app.get("/entities/{entity_id}/properties")
def get_properties(entity_id: int) -> Dict[str, Any]:
with db_cursor() as cur:
props = property_core.get_properties(cur, entity_id)
return {"entity_id": entity_id, "properties": props}
@app.delete("/properties")
def delete_property(payload: DeletePropertyIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
property_core.delete_property(cur, payload.entity_id, payload.property_name)
return {"ok": True}
except (ValueError, TypeError) as e:
raise bad_request(e)
# ---- Metadata ----
@app.get("/metadata")
def get_metadata() -> Dict[str, Any]:
with db_cursor() as cur:
return {
"name": metadata_core.get_name(cur),
"comment": metadata_core.get_comment(cur),
"public_key": metadata_core.get_public_key(cur),
"defense_p": metadata_core.get_defense_p(cur),
}
@app.post("/metadata/name")
def set_metadata_name(payload: SetMetadataNameIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
metadata_core.set_name(cur, payload.name)
return {"ok": True}
except (ValueError, TypeError) as e:
raise bad_request(e)
@app.post("/metadata/defense_p")
def set_metadata_defense_p(payload: SetMetadataDefensePIn) -> Dict[str, Any]:
try:
with db_cursor() as cur:
metadata_core.set_defense_p(cur, payload.defense_p)
return {"ok": True}
except (ValueError, TypeError) as e:
raise bad_request(e)

24
ca_api/db.py Normal file
View File

@ -0,0 +1,24 @@
# ca_api/db.py
from __future__ import annotations
import os
from contextlib import contextmanager
import psycopg
from psycopg.rows import dict_row
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/ca")
@contextmanager
def db_cursor():
"""
Transaction-per-request.
Uses dict_row because ca_core expects dict-like rows (row["status"], etc.).
"""
conn = psycopg.connect(DATABASE_URL, row_factory=dict_row)
try:
with conn: # commits on success, rolls back on exception
with conn.cursor() as cur:
yield cur
finally:
conn.close()

View File

@ -1,4 +1,4 @@
from db_logging import log_change
from .db_logging import log_change
def ensure_entity_active(cursor, entity_id):

View File

@ -1,5 +1,5 @@
from db_logging import log_change
from entity import ensure_entity_active
from .db_logging import log_change
from .entity import ensure_entity_active
def _get_entity_type(cursor, entity_id):

View File

@ -1,4 +1,4 @@
from db_logging import log_change
from .db_logging import log_change
def _ensure_singleton_row(cursor):

View File

@ -1,5 +1,5 @@
from db_logging import log_change
from entity import ensure_entity_active
from .db_logging import log_change
from .entity import ensure_entity_active
def _validate_validation_policy(validation_policy: str) -> str:

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,7 +8,7 @@ from pathlib import Path
code_path = Path(__file__).parents[1] / "ca_core"
sys.path.insert(0, str(code_path))
from crypto.zenroom_client import ZenroomDockerClient, ZenroomError
from ca_core.crypto.zenroom_client import ZenroomDockerClient, ZenroomError
def _docker_ok():

View File

@ -7,7 +7,7 @@ from pathlib import Path
code_path = Path(__file__).parent.parent.parent / "ca_core"
sys.path.insert(0, str(code_path))
from crypto.zenroom_service_client import ZenroomServiceClient
from ca_core.crypto.zenroom_service_client import ZenroomServiceClient
def _live_enabled() -> bool:

View File

@ -7,7 +7,7 @@ from pathlib import Path
code_path = Path(__file__).parents[2] / "ca_core"
sys.path.insert(0, str(code_path))
from crypto.zenroom_service_client import ZenroomServiceClient
from ca_core.crypto.zenroom_service_client import ZenroomServiceClient
class TestZenroomServiceClientIntegration(unittest.TestCase):

View File

@ -0,0 +1,187 @@
import os
import unittest
from pathlib import Path
import psycopg
from fastapi.testclient import TestClient
# Run these tests only when a real DB is provided.
DBURL = os.getenv("DATABASE_URL")
ROOT = Path(__file__).resolve().parents[1]
CREATE_TABLES = ROOT / "create_tables.sql"
@unittest.skipUnless(DBURL, "DATABASE_URL not set; skipping API integration tests")
class TestApiIntegration(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Ensure the API uses the same DB as the tests.
os.environ["DATABASE_URL"] = DBURL
# Import after setting env so ca_api.db picks it up.
from ca_api.app import app # noqa: WPS433
cls.client = TestClient(app)
# Recreate tables from scratch for a clean slate.
schema_sql = CREATE_TABLES.read_text(encoding="utf-8")
with psycopg.connect(DBURL) as conn:
with conn.cursor() as cur:
cur.execute(schema_sql)
def _log_count(self) -> int:
with psycopg.connect(DBURL) as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM log")
return int(cur.fetchone()[0])
def test_01_health(self):
r = self.client.get("/health")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {"ok": True})
def test_02_creator_person_roundtrip_and_log(self):
before = self._log_count()
r = self.client.post("/creators", json={"name": "Alice", "public_key": "pk-alice"})
self.assertEqual(r.status_code, 201)
creator_id = r.json()["id"]
# Creating a creator should log at least one entry (your core logs mutations).
after = self._log_count()
self.assertGreaterEqual(after, before + 1)
r = self.client.post(
"/persons",
json={"name": "Bob", "public_key": "pk-bob", "creator_id": creator_id},
)
self.assertEqual(r.status_code, 201)
person_id = r.json()["id"]
r = self.client.get(f"/entities/{person_id}")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(body["id"], person_id)
self.assertEqual(body["name"], "Bob")
self.assertEqual(body["type"], "person")
def test_03_group_create_add_member_list_members(self):
r = self.client.post("/creators", json={"name": "Admin", "public_key": "pk-admin"})
self.assertEqual(r.status_code, 201)
creator_id = r.json()["id"]
r = self.client.post(
"/groups",
json={
"name": "Engineering",
"public_key": "pk-eng",
"creator_id": creator_id,
"ca_reference": "ca-ref-001",
},
)
self.assertEqual(r.status_code, 201)
group_id = r.json()["id"]
r = self.client.post(
"/persons",
json={"name": "Carol", "public_key": "pk-carol", "creator_id": creator_id},
)
self.assertEqual(r.status_code, 201)
member_id = r.json()["id"]
r = self.client.post(
"/groups/members",
json={"group_id": group_id, "member_id": member_id, "role": "member"},
)
self.assertEqual(r.status_code, 201)
self.assertEqual(r.json(), {"ok": True})
r = self.client.get(f"/groups/{group_id}/members")
self.assertEqual(r.status_code, 200)
members = r.json()
self.assertTrue(any(m["member_id"] == member_id for m in members))
def test_04_property_set_get_delete(self):
r = self.client.post("/creators", json={"name": "PropAdmin", "public_key": "pk-pa"})
self.assertEqual(r.status_code, 201)
creator_id = r.json()["id"]
r = self.client.post(
"/persons",
json={"name": "Dave", "public_key": "pk-dave", "creator_id": creator_id},
)
self.assertEqual(r.status_code, 201)
entity_id = r.json()["id"]
r = self.client.post(
"/properties",
json={
"entity_id": entity_id,
"property_name": "email",
"validation_policy": "default",
"source": "hr-system",
},
)
self.assertEqual(r.status_code, 201)
self.assertEqual(r.json(), {"ok": True})
r = self.client.get(f"/entities/{entity_id}/properties")
self.assertEqual(r.status_code, 200)
props = r.json()["properties"]
self.assertIn("email", props)
r = self.client.request(
"DELETE",
"/properties",
json={"entity_id": entity_id, "property_name": "email"},
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {"ok": True})
r = self.client.get(f"/entities/{entity_id}/properties")
self.assertEqual(r.status_code, 200)
props = r.json()["properties"]
self.assertNotIn("email", props)
def test_05_revoked_entity_is_immutable(self):
r = self.client.post("/creators", json={"name": "Revoker", "public_key": "pk-r"})
self.assertEqual(r.status_code, 201)
creator_id = r.json()["id"]
r = self.client.post(
"/persons",
json={"name": "Eve", "public_key": "pk-eve", "creator_id": creator_id},
)
self.assertEqual(r.status_code, 201)
eve_id = r.json()["id"]
# Revoke Eve
r = self.client.post(f"/entities/{eve_id}/status?status=revoked&changed_by={creator_id}")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {"ok": True})
# Attempt to mutate a revoked entity (should be rejected by core rule)
r = self.client.post(
"/properties",
json={"entity_id": eve_id, "property_name": "email", "validation_policy": "default"},
)
self.assertEqual(r.status_code, 400)
# detail message depends on your core exception text; just confirm it's a 400.
self.assertIn("detail", r.json())
def test_06_metadata_set_and_get(self):
r = self.client.post("/metadata/name", json={"name": "My CA"})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {"ok": True})
r = self.client.post("/metadata/defense_p", json={"defense_p": True})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {"ok": True})
r = self.client.get("/metadata")
self.assertEqual(r.status_code, 200)
body = r.json()
self.assertEqual(body["name"], "My CA")
self.assertEqual(body["defense_p"], True)

15
tests/test_api_smoke.py Normal file
View File

@ -0,0 +1,15 @@
import unittest
from fastapi.testclient import TestClient
from ca_api.app import app
class TestApiSmoke(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.client = TestClient(app)
def test_health(self):
r = self.client.get("/health")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {"ok": True})

83
tests/test_api_unit.py Normal file
View File

@ -0,0 +1,83 @@
import unittest
from contextlib import contextmanager
from unittest.mock import patch
from fastapi.testclient import TestClient
from ca_api.app import app
class _FakeCursor:
pass
@contextmanager
def _fake_db_cursor():
# Mimics ca_api.db.db_cursor() which yields a cursor
yield _FakeCursor()
class TestApiUnit(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.client = TestClient(app)
def test_create_creator_201(self):
with patch("ca_api.app.db_cursor", _fake_db_cursor), \
patch("ca_api.app.entity_core.insert_creator", return_value=123) as m_insert:
payload = {"name": "Alice", "public_key": "pk-alice"}
r = self.client.post("/creators", json=payload)
self.assertEqual(r.status_code, 201)
self.assertEqual(r.json(), {"id": 123})
# Ensure core called with (cursor, name, public_key)
args, kwargs = m_insert.call_args
self.assertIsInstance(args[0], _FakeCursor)
self.assertEqual(args[1], "Alice")
self.assertEqual(args[2], "pk-alice")
self.assertEqual(kwargs, {})
def test_get_entity_404(self):
with patch("ca_api.app.db_cursor", _fake_db_cursor), \
patch("ca_api.app.entity_core.get_entity", return_value=None):
r = self.client.get("/entities/99999")
self.assertEqual(r.status_code, 404)
self.assertEqual(r.json()["detail"], "Entity not found")
def test_get_entity_200(self):
# FastAPI returns dict(row), so return any mapping-like object here
with patch("ca_api.app.db_cursor", _fake_db_cursor), \
patch("ca_api.app.entity_core.get_entity", return_value={"id": 7, "name": "Bob"}):
r = self.client.get("/entities/7")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["id"], 7)
self.assertEqual(r.json()["name"], "Bob")
def test_set_property_400_on_value_error(self):
def _raise(*args, **kwargs):
raise ValueError("bad property")
with patch("ca_api.app.db_cursor", _fake_db_cursor), \
patch("ca_api.app.property_core.set_property", side_effect=_raise):
payload = {"entity_id": 1, "property_name": "email", "validation_policy": "default"}
r = self.client.post("/properties", json=payload)
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json()["detail"], "bad property")
def test_add_member_201(self):
with patch("ca_api.app.db_cursor", _fake_db_cursor), \
patch("ca_api.app.group_core.add_group_member", return_value=None) as m_add:
payload = {"group_id": 10, "member_id": 20, "role": "admin"}
r = self.client.post("/groups/members", json=payload)
self.assertEqual(r.status_code, 201)
self.assertEqual(r.json(), {"ok": True})
args, kwargs = m_add.call_args
self.assertIsInstance(args[0], _FakeCursor)
self.assertEqual(args[1:], (10, 20, "admin"))
self.assertEqual(kwargs, {})

View File

@ -6,7 +6,7 @@ import psycopg
code_path = Path(__file__).parent.parent / "ca_core"
sys.path.insert(0, str(code_path))
import entity
from ca_core import entity
DBNAME = "ca"

View File

@ -6,8 +6,8 @@ import psycopg
code_path = Path(__file__).parent.parent / "ca_core"
sys.path.insert(0, str(code_path))
import entity
import group_member
from ca_core import entity
from ca_core import group_member
DBNAME = "ca"

View File

@ -1,7 +1,7 @@
import os
import unittest
from crypto.zenroom_service_client import ZenroomServiceClient
from ca_core.crypto.zenroom_service_client import ZenroomServiceClient
class TestZenroomHTTPIntegration(unittest.TestCase):

View File

@ -6,7 +6,7 @@ import psycopg
code_path = Path(__file__).parent.parent / "ca_core"
sys.path.insert(0, str(code_path))
import metadata
from ca_core import metadata
DBNAME = "ca"

View File

@ -7,8 +7,8 @@ import psycopg
code_path = Path(__file__).parent.parent / "ca_core"
sys.path.insert(0, str(code_path))
import entity
import property
from ca_core import entity
from ca_core import property
DBNAME = "ca"

View File

@ -7,7 +7,7 @@ from unittest import mock
code_path = Path(__file__).parent.parent / "ca_core"
sys.path.insert(0, str(code_path))
from crypto.zenroom_client import ZenroomDockerClient, ZenroomError
from ca_core.crypto.zenroom_client import ZenroomDockerClient, ZenroomError
class TestZenroomDockerClient(unittest.TestCase):

View File

@ -7,7 +7,7 @@ from pathlib import Path
code_path = Path(__file__).parents[1] / "ca_core"
sys.path.insert(0, str(code_path))
from crypto.zenroom_service_client import ZenroomServiceClient, ZenroomServiceError
from ca_core.crypto.zenroom_service_client import ZenroomServiceClient, ZenroomServiceError
class _FakeHTTPResponse:

View File

@ -7,7 +7,7 @@ from pathlib import Path
code_path = Path(__file__).parents[1] / "ca_core"
sys.path.insert(0, str(code_path))
from crypto.zenroom_service_client import ZenroomServiceClient
from ca_core.crypto.zenroom_service_client import ZenroomServiceClient
class _FakeHTTPResponse: