diff --git a/e2e/dune b/e2e/dune index a2b9a02..475f6ab 100644 --- a/e2e/dune +++ b/e2e/dune @@ -1,6 +1,6 @@ (library (name e2e) - (modules config nostr_signer http_client test_upload test_delete test_cors test_get test_head_upload test_mirror test_list) + (modules config nostr_signer http_client test_upload test_delete test_cors test_get test_head_upload test_mirror test_list test_report) (libraries blossoML.core piaf diff --git a/e2e/main.ml b/e2e/main.ml index 9ed96b7..05b07e9 100644 --- a/e2e/main.ml +++ b/e2e/main.ml @@ -38,4 +38,7 @@ let () = Eio.traceln "\nList Tests (BUD-12):"; List.iter (run_test ~sw ~env) E2e.Test_list.tests; + Eio.traceln "\nReport Tests (BUD-09):"; + List.iter (run_test ~sw ~env) E2e.Test_report.tests; + Eio.traceln "\nDone." diff --git a/e2e/nostr_signer.ml b/e2e/nostr_signer.ml index c834cf3..048dd32 100644 --- a/e2e/nostr_signer.ml +++ b/e2e/nostr_signer.ml @@ -99,6 +99,29 @@ let create_list_auth ~keypair ~created_at ~expiration = | Ok (sig_, _) -> { Nostr_event.id; pubkey = keypair.pubkey; created_at; kind; tags; content; sig_ } +(** Create a BUD-09 / NIP-56 report event (kind 1984). + [entries] is a list of (sha256, report_type) pairs. *) +let create_report ~keypair ~created_at ~entries ?(content = "") ?e_tag ?p_tag () = + let created_at = Int64.of_float created_at in + let x_tags = List.map (fun (sha, rt) -> ["x"; sha; rt]) entries in + let extra_tags = + (match e_tag with Some v -> [["e"; v]] | None -> []) + @ (match p_tag with Some v -> [["p"; v]] | None -> []) + in + let tags = x_tags @ extra_tags in + let kind = 1984 in + let id = Nostr_event.compute_id + ~pubkey:keypair.pubkey + ~created_at + ~kind + ~tags + ~content + in + match Bip340.sign ~secret_key:keypair.secret_key ~msg:id with + | Error _ -> failwith "Failed to sign report event" + | Ok (sig_, _) -> + { Nostr_event.id; pubkey = keypair.pubkey; created_at; kind; tags; content; sig_ } + (** Convert a Nostr event to JSON string. *) let event_to_json event = let open Nostr_event in diff --git a/e2e/test_get.ml b/e2e/test_get.ml index 5ece510..3621f27 100644 --- a/e2e/test_get.ml +++ b/e2e/test_get.ml @@ -254,12 +254,12 @@ let test_get_binary_blob ~sw ~env = let test_get_invalid_paths ~sw ~env = let base_url = Config.base_url in + (* Note: GET / is no longer invalid — it returns 200 with Terms of Service (BUD-09). + See test_report.ml for that case. *) let invalid_paths = [ - "/"; (* Root path *) "/upload"; (* Upload endpoint *) "/../etc/passwd"; (* Path traversal attempt *) "/a/b/c"; (* Multiple segments *) - ""; (* Empty path *) ] in List.iter (fun path -> @@ -638,9 +638,9 @@ let test_head_get_consistent_headers ~sw ~env = let test_head_invalid_paths ~sw ~env = let base_url = Config.base_url in - (* Note: /upload is now a valid endpoint (BUD-06), so it's not included here *) + (* Note: /upload is now a valid endpoint (BUD-06), so it's not included here. + GET / is now Terms of Service (BUD-09); HEAD / would still 404. *) let invalid_paths = [ - "/"; "/../etc/passwd"; "/a/b/c"; ] in diff --git a/e2e/test_report.ml b/e2e/test_report.ml new file mode 100644 index 0000000..2be84c1 --- /dev/null +++ b/e2e/test_report.ml @@ -0,0 +1,132 @@ +(** E2E tests for BUD-09 PUT /report and GET / (Terms of Service). *) + +let sha256_hex content = + let hash = Digestif.SHA256.digest_string content in + Digestif.SHA256.to_hex hash + +(** Test: GET / returns 200 and text/plain Terms of Service *) +let test_get_root_terms_of_service ~sw ~env = + let base_url = Config.base_url in + let result = Http_client.get ~sw ~env ~url:base_url () in + match result with + | Error e -> failwith ("GET / failed: " ^ e) + | Ok response -> + if response.status <> 200 then + failwith (Printf.sprintf "Expected 200, got %d" response.status); + let ct = List.find_opt + (fun (k, _) -> String.lowercase_ascii k = "content-type") response.headers + in + (match ct with + | None -> failwith "Missing Content-Type" + | Some (_, v) -> + if not (String.starts_with ~prefix:"text/plain" v) then + failwith (Printf.sprintf "Expected text/plain, got %s" v)); + if String.length response.body = 0 then + failwith "Empty ToS body"; + (* must mention the report endpoint *) + let contains s sub = + try + let len_sub = String.length sub in + let len_s = String.length s in + let rec loop i = + if i + len_sub > len_s then false + else if String.sub s i len_sub = sub then true + else loop (i + 1) + in loop 0 + with _ -> false + in + if not (contains response.body "report") then + failwith "ToS should mention reporting" + +(** Test: PUT /report with a valid signed report returns 200 *) +let test_put_report_accepted ~sw ~env = + let base_url = Config.base_url in + let clock = Eio.Stdenv.clock env in + let now = Eio.Time.now clock in + let keypair = Nostr_signer.generate_keypair () in + let some_sha = sha256_hex "irrelevant content for report-only test" in + let event = Nostr_signer.create_report + ~keypair ~created_at:now + ~entries:[(some_sha, "spam")] + ~content:"this is spam" + () + in + let body = Nostr_signer.event_to_json event in + let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report") + ~headers:[("Content-Type", "application/json")] ~body () + in + match result with + | Error e -> failwith ("PUT /report failed: " ^ e) + | Ok response -> + if response.status <> 200 then + failwith (Printf.sprintf "Expected 200, got %d (body: %s)" + response.status response.body) + +(** Test: PUT /report with multiple x tags is accepted *) +let test_put_report_multiple_x_tags ~sw ~env = + let base_url = Config.base_url in + let clock = Eio.Stdenv.clock env in + let now = Eio.Time.now clock in + let keypair = Nostr_signer.generate_keypair () in + let sha1 = sha256_hex "blob 1" in + let sha2 = sha256_hex "blob 2" in + let event = Nostr_signer.create_report + ~keypair ~created_at:now + ~entries:[(sha1, "malware"); (sha2, "illegal")] + ~content:"" + () + in + let body = Nostr_signer.event_to_json event in + let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report") + ~headers:[("Content-Type", "application/json")] ~body () + in + match result with + | Error e -> failwith ("PUT /report failed: " ^ e) + | Ok response -> + if response.status <> 200 then + failwith (Printf.sprintf "Expected 200, got %d (body: %s)" + response.status response.body) + +(** Test: PUT /report rejects an invalid (non-NIP-56) body *) +let test_put_report_rejects_invalid_body ~sw ~env = + let base_url = Config.base_url in + let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report") + ~headers:[("Content-Type", "application/json")] + ~body:"{\"not\":\"a report\"}" () + in + match result with + | Error e -> failwith ("Request failed: " ^ e) + | Ok response -> + if response.status <> 400 then + failwith (Printf.sprintf "Expected 400 for invalid body, got %d" response.status) + +(** Test: PUT /report rejects an unknown report type *) +let test_put_report_rejects_unknown_type ~sw ~env = + let base_url = Config.base_url in + let clock = Eio.Stdenv.clock env in + let now = Eio.Time.now clock in + let keypair = Nostr_signer.generate_keypair () in + let some_sha = sha256_hex "x" in + let event = Nostr_signer.create_report + ~keypair ~created_at:now + ~entries:[(some_sha, "vandalism")] + ~content:"" + () + in + let body = Nostr_signer.event_to_json event in + let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report") + ~headers:[("Content-Type", "application/json")] ~body () + in + match result with + | Error e -> failwith ("Request failed: " ^ e) + | Ok response -> + if response.status <> 400 then + failwith (Printf.sprintf "Expected 400 for invalid report type, got %d" response.status) + +let tests = [ + ("GET / returns ToS", test_get_root_terms_of_service); + ("PUT /report accepted", test_put_report_accepted); + ("PUT /report multiple x tags", test_put_report_multiple_x_tags); + ("PUT /report rejects invalid body", test_put_report_rejects_invalid_body); + ("PUT /report rejects unknown type", test_put_report_rejects_unknown_type); +] diff --git a/lib/core/domain.ml b/lib/core/domain.ml index 4876d23..b3f17d0 100644 --- a/lib/core/domain.ml +++ b/lib/core/domain.ml @@ -19,6 +19,7 @@ type error = | Mirror_invalid_url of string (* invalid mirror URL -> 400 *) | Mirror_fetch_error of string (* remote fetch failed -> 502 *) | Mirror_ssrf_blocked of string (* SSRF protection blocked URL -> 400, detail for logging *) + | Report_error of string (* invalid NIP-56 report event -> 400 *) (** Mirror request body *) type mirror_request = { diff --git a/lib/core/report.ml b/lib/core/report.ml new file mode 100644 index 0000000..97943bc --- /dev/null +++ b/lib/core/report.ml @@ -0,0 +1,142 @@ +(** NIP-56 blob report event validation (BUD-09). + + A report is a kind:1984 Nostr event with one or more `x` tags, each + containing the sha256 of a reported blob and a report type. *) + +type report_type = + | Nudity + | Malware + | Profanity + | Illegal + | Spam + | Impersonation + | Other + +let report_type_of_string = function + | "nudity" -> Some Nudity + | "malware" -> Some Malware + | "profanity" -> Some Profanity + | "illegal" -> Some Illegal + | "spam" -> Some Spam + | "impersonation" -> Some Impersonation + | "other" -> Some Other + | _ -> None + +let report_type_to_string = function + | Nudity -> "nudity" + | Malware -> "malware" + | Profanity -> "profanity" + | Illegal -> "illegal" + | Spam -> "spam" + | Impersonation -> "impersonation" + | Other -> "other" + +type entry = { + sha256 : string; + report_type : report_type; +} + +type t = { + event_id : string; + reporter_pubkey : string; + created_at : int64; + entries : entry list; + content : string; + e_tag : string option; + p_tag : string option; + raw_event_json : string; +} + +(** Parse a JSON string as a Nostr event. *) +let parse_event_json (body : string) : (Nostr_event.t, Domain.error) result = + try + let json = Yojson.Safe.from_string body in + let open Yojson.Safe.Util in + let event : Nostr_event.t = { + id = json |> member "id" |> to_string; + pubkey = json |> member "pubkey" |> to_string; + created_at = json |> member "created_at" |> to_int |> Int64.of_int; + kind = json |> member "kind" |> to_int; + tags = json |> member "tags" |> to_list |> List.map (fun tag -> tag |> to_list |> List.map to_string); + content = json |> member "content" |> to_string; + sig_ = json |> member "sig" |> to_string; + } in + Ok event + with + | Yojson.Json_error msg -> + Error (Domain.Report_error ("JSON parse error: " ^ msg)) + | Yojson.Safe.Util.Type_error (msg, _) -> + Error (Domain.Report_error ("JSON type error: " ^ msg)) + +(** Extract all `x` tag entries (sha256, type). Each x tag MUST be + ["x", "", ""]. *) +let extract_entries (event : Nostr_event.t) : (entry list, Domain.error) result = + let rec collect acc = function + | [] -> Ok (List.rev acc) + | tag :: rest -> + (match tag with + | "x" :: sha :: type_str :: _ -> + if not (Integrity.validate_hash sha) then + Error (Domain.Report_error ("Invalid sha256 in x tag: " ^ sha)) + else + (match report_type_of_string type_str with + | None -> + Error (Domain.Report_error + (Printf.sprintf "Invalid report type '%s' (expected nudity/malware/profanity/illegal/spam/impersonation/other)" type_str)) + | Some rt -> + collect ({ sha256 = sha; report_type = rt } :: acc) rest) + | "x" :: _ -> + Error (Domain.Report_error "x tag must contain sha256 and report type") + | _ -> collect acc rest) + in + match collect [] event.tags with + | Error e -> Error e + | Ok [] -> Error (Domain.Report_error "Report event must contain at least one x tag") + | Ok entries -> Ok entries + +(** Validate the Nostr event's id and signature. *) +let verify_event (event : Nostr_event.t) : (unit, Domain.error) result = + if not (Nostr_event.verify_id event) then + Error (Domain.Report_error "Event ID does not match computed hash") + else + match Nostr_event.verify_signature event with + | Ok () -> Ok () + | Error Nostr_event.Invalid_id_format -> + Error (Domain.Report_error "Invalid event ID format") + | Error Nostr_event.Invalid_pubkey_format -> + Error (Domain.Report_error "Invalid pubkey format") + | Error Nostr_event.Invalid_signature_format -> + Error (Domain.Report_error "Invalid signature format") + | Error Nostr_event.Signature_mismatch -> + Error (Domain.Report_error "Invalid signature") + +(** Validate a NIP-56 report body for BUD-09 PUT /report. + [current_time] is accepted for future use (e.g., rejecting events + created far in the future). Currently we do not require an + `expiration` tag, as NIP-56 does not mandate one. *) +let validate ~current_time (body : string) : (t, Domain.error) result = + let _ = current_time in + match parse_event_json body with + | Error e -> Error e + | Ok event -> + if event.kind <> 1984 then + Error (Domain.Report_error "Invalid event kind, must be 1984") + else + match extract_entries event with + | Error e -> Error e + | Ok entries -> + match verify_event event with + | Error e -> Error e + | Ok () -> + let e_tag = Nostr_event.find_tag event "e" in + let p_tag = Nostr_event.find_tag event "p" in + Ok { + event_id = event.id; + reporter_pubkey = event.pubkey; + created_at = event.created_at; + entries; + content = event.content; + e_tag; + p_tag; + raw_event_json = body; + } diff --git a/lib/shell/blossom_db.ml b/lib/shell/blossom_db.ml index 623c4e6..4f5767f 100644 --- a/lib/shell/blossom_db.ml +++ b/lib/shell/blossom_db.ml @@ -48,6 +48,31 @@ module Db = struct CREATE INDEX IF NOT EXISTS blob_owners_pubkey ON blob_owners(pubkey) |sql} + let create_blob_reports_table = + (unit ->. unit) + @@ {sql| + CREATE TABLE IF NOT EXISTS blob_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT(64) NOT NULL, + sha256 TEXT(64) NOT NULL, + reporter_pubkey TEXT(64) NOT NULL, + report_type TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + e_tag TEXT, + p_tag TEXT, + raw_event_json TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + received_at INTEGER NOT NULL, + UNIQUE(event_id, sha256) + ) + |sql} + + let create_blob_reports_sha256_index = + (unit ->. unit) + @@ {sql| + CREATE INDEX IF NOT EXISTS blob_reports_sha256 ON blob_reports(sha256) + |sql} + let save_blob = (t3 string (option string) (option int64) ->. unit) @@ {sql| @@ -56,6 +81,17 @@ module Db = struct ON CONFLICT(sha256) DO UPDATE SET status = 'stored', uploaded_at = strftime('%s', 'now') |sql} + let insert_report = + (t10 string string string string string (option string) (option string) string int64 int64 + ->. unit) + @@ {sql| + INSERT INTO blob_reports + (event_id, sha256, reporter_pubkey, report_type, content, + e_tag, p_tag, raw_event_json, event_created_at, received_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT(event_id, sha256) DO NOTHING + |sql} + let get_blob = (string ->? t4 string int64 (option string) (option int64)) @@ {sql| @@ -140,7 +176,9 @@ let init ~env ~sw ~dir = Result.bind (C.exec Db.create_blobs_uploaded_at_index ()) @@ fun () -> Result.bind (C.exec Db.create_blob_owners_table ()) @@ fun () -> Result.bind (C.exec Db.create_blob_owners_sha256_index ()) @@ fun () -> - C.exec Db.create_blob_owners_pubkey_index () + Result.bind (C.exec Db.create_blob_owners_pubkey_index ()) @@ fun () -> + Result.bind (C.exec Db.create_blob_reports_table ()) @@ fun () -> + C.exec Db.create_blob_reports_sha256_index () ) pool in match init_result with @@ -261,6 +299,19 @@ let list_by_pubkey pool ~pubkey ~since ~until ~cursor ~limit = Ok descriptors | Error e -> Error (Domain.Storage_error (Caqti_error.show e)) +let save_report pool ~event_id ~sha256 ~reporter_pubkey ~report_type + ~content ~e_tag ~p_tag ~raw_event_json ~event_created_at ~received_at = + let result = + Caqti_eio.Pool.use (fun (module C : Caqti_eio.CONNECTION) -> + C.exec Db.insert_report + (event_id, sha256, reporter_pubkey, report_type, content, + e_tag, p_tag, raw_event_json, event_created_at, received_at) + ) pool + in + match result with + | Ok () -> Ok () + | Error e -> Error (Domain.Storage_error (Caqti_error.show e)) + (** Db_intf.S を満たすモジュール *) module Impl : Db_intf.S with type t = t = struct type nonrec t = t @@ -273,4 +324,5 @@ module Impl : Db_intf.S with type t = t = struct let count_owners = count_owners let list_owners = list_owners let list_by_pubkey = list_by_pubkey + let save_report = save_report end diff --git a/lib/shell/db_intf.ml b/lib/shell/db_intf.ml index efb6f76..cd2b0e0 100644 --- a/lib/shell/db_intf.ml +++ b/lib/shell/db_intf.ml @@ -73,4 +73,20 @@ module type S = sig cursor:(int64 * string) option -> limit:int -> (Domain.blob_descriptor list, Domain.error) result + + (** BUD-09: NIP-56 レポートを1件保存する。 + 同じ (event_id, sha256) 組は重複保存しない。 *) + val save_report : + t -> + event_id:string -> + sha256:string -> + reporter_pubkey:string -> + report_type:string -> + content:string -> + e_tag:string option -> + p_tag:string option -> + raw_event_json:string -> + event_created_at:int64 -> + received_at:int64 -> + (unit, Domain.error) result end diff --git a/lib/shell/http_response.ml b/lib/shell/http_response.ml index 72a3f63..991f916 100644 --- a/lib/shell/http_response.ml +++ b/lib/shell/http_response.ml @@ -23,6 +23,10 @@ type response_kind = (** Blob削除成功 *) | Success_upload_check (** アップロード事前チェック成功(HEAD /upload用) *) + | Success_report + (** BUD-09: PUT /report 受理成功 *) + | Success_terms_of_service of string + (** BUD-09: GET / で返すサーバー規約(text/plain) *) | Cors_preflight (** CORSプリフライトレスポンス *) | Error_not_found of string @@ -131,6 +135,19 @@ let create = function let headers = Headers.of_list cors_headers in Response.create ~headers `OK + | Success_report -> + let json = `Assoc [("message", `String "Report received")] |> Yojson.Basic.to_string in + let headers = Headers.of_list (cors_headers @ [ + ("content-type", "application/json"); + ]) in + Response.create ~headers ~body:(Body.of_string json) `OK + + | Success_terms_of_service body -> + let headers = Headers.of_list (cors_headers @ [ + ("content-type", "text/plain; charset=utf-8"); + ]) in + Response.create ~headers ~body:(Body.of_string body) `OK + | Cors_preflight -> let headers = Headers.of_list cors_headers in Response.create ~headers `No_content diff --git a/lib/shell/http_server.ml b/lib/shell/http_server.ml index ee296a9..d643f40 100644 --- a/lib/shell/http_server.ml +++ b/lib/shell/http_server.ml @@ -36,6 +36,43 @@ let error_to_response_kind = function | Domain.Mirror_invalid_url msg -> Http_response.Error_bad_request msg | Domain.Mirror_fetch_error msg -> Http_response.Error_bad_gateway msg | Domain.Mirror_ssrf_blocked _msg -> Http_response.Error_bad_request "URL not allowed" + | Domain.Report_error msg -> Http_response.Error_bad_request msg + +(** BUD-09: GET / で返すサーバー規約(terms of service)テキスト *) +let terms_of_service = + let policy = Policy.default_policy in + Printf.sprintf +{|blossoML - Terms of Service +=========================== + +This is a Blossom (BUD-01..) server. By uploading, mirroring, or +otherwise interacting with this server you agree to the following: + +1. Acceptable use + - No illegal content (including but not limited to CSAM). + - No malware or other harmful software. + - No content that violates the rights of others. + +2. Limits + - Maximum blob size: %d bytes. + - The operator may reject uploads based on MIME type or other + policy at any time. + +3. Reporting (BUD-09) + - Send a signed NIP-56 (kind:1984) event to PUT /report. + - Each `x` tag MUST contain the sha256 of the reported blob and + one of the following report types: + nudity / malware / profanity / illegal / spam / + impersonation / other. + +4. Moderation + - Reports are stored for operator review. + - The operator may remove or refuse content at their discretion. + +5. No warranty + - This service is provided as-is, without warranty of any kind. +|} + policy.max_size let request_handler ~sw ~env ~clock ~data_dir ~db ~base_url { Server.Handler.request; _ } = Eio.traceln "Request: %s %s" (Method.to_string request.meth) request.target; @@ -51,6 +88,9 @@ let request_handler ~sw ~env ~clock ~data_dir ~db ~base_url { Server.Handler.req let path_parts = String.split_on_char '/' path |> List.filter (fun s -> s <> "") in Eio.traceln "Path parts: [%s]" (String.concat "; " path_parts); (match path_parts with + | [] -> + (* BUD-09: GET / returns server's terms of service *) + Http_response.Success_terms_of_service terms_of_service | [hash_with_ext] -> let hash = try Filename.remove_extension hash_with_ext with _ -> hash_with_ext in if not (Integrity.validate_hash hash) then @@ -200,6 +240,48 @@ let request_handler ~sw ~env ~clock ~data_dir ~db ~base_url { Server.Handler.req | Error e -> error_to_response_kind e) | _ -> Http_response.Error_not_found "Invalid path") + | `PUT, "/report" -> + (* BUD-09: signed NIP-56 (kind 1984) report event in body *) + let body_str = + match Piaf.Body.to_string request.body with + | Ok s -> s + | Error _ -> "" + in + let current_time = Int64.of_float (Eio.Time.now clock) in + (match Report.validate ~current_time body_str with + | Error e -> error_to_response_kind e + | Ok report -> + let received_at = current_time in + let rec persist = function + | [] -> Ok () + | (entry : Report.entry) :: rest -> + let report_type_str = Report.report_type_to_string entry.report_type in + (match Blossom_db.save_report db + ~event_id:report.event_id + ~sha256:entry.sha256 + ~reporter_pubkey:report.reporter_pubkey + ~report_type:report_type_str + ~content:report.content + ~e_tag:report.e_tag + ~p_tag:report.p_tag + ~raw_event_json:report.raw_event_json + ~event_created_at:report.created_at + ~received_at with + | Error e -> Error e + | Ok () -> persist rest) + in + (match persist report.entries with + | Error e -> + Eio.traceln "Failed to persist report %s: %s" + report.event_id + (match e with Domain.Storage_error m -> m | _ -> "unknown"); + error_to_response_kind e + | Ok () -> + Eio.traceln "Report received: event=%s reporter=%s entries=%d" + report.event_id report.reporter_pubkey + (List.length report.entries); + Http_response.Success_report)) + | `PUT, "/upload" -> (match Headers.get request.headers "authorization" with | None -> Http_response.Error_unauthorized "Missing Authorization header" diff --git a/test/dune b/test/dune index c8466f1..98edd82 100644 --- a/test/dune +++ b/test/dune @@ -1,4 +1,4 @@ (test (name test_runner) - (modules test_runner test_integrity test_policy test_auth test_bip340 test_http_response test_mime_detect test_content_type test_nostr_event test_nostr_signer test_mirror) + (modules test_runner test_integrity test_policy test_auth test_bip340 test_http_response test_mime_detect test_content_type test_nostr_event test_nostr_signer test_mirror test_report) (libraries blossoML.core blossoML.shell e2e alcotest base64)) diff --git a/test/test_report.ml b/test/test_report.ml new file mode 100644 index 0000000..562070f --- /dev/null +++ b/test/test_report.ml @@ -0,0 +1,221 @@ +open Alcotest +open Blossom_core + +(** Helper to build a signed NIP-56 (kind 1984) report event JSON. *) +let make_report_json ~secret_key ~pubkey ~created_at ~tags ~content = + let kind = 1984 in + let id = Nostr_event.compute_id ~pubkey ~created_at ~kind ~tags ~content in + let sig_ = + match Bip340.sign ~secret_key ~msg:id with + | Ok (s, _) -> s + | Error _ -> failwith "signing failed" + in + let tags_json = + tags + |> List.map (fun tag -> `List (List.map (fun s -> `String s) tag)) + in + let json = `Assoc [ + ("id", `String id); + ("pubkey", `String pubkey); + ("created_at", `Int (Int64.to_int created_at)); + ("kind", `Int kind); + ("tags", `List tags_json); + ("content", `String content); + ("sig", `String sig_); + ] in + Yojson.Safe.to_string json + +let fresh_keypair () = + let random_bytes = Bytes.create 32 in + for i = 0 to 31 do Bytes.set random_bytes i (Char.chr (Random.int 256)) done; + let buf = Buffer.create 64 in + Bytes.iter (fun c -> Buffer.add_string buf (Printf.sprintf "%02x" (Char.code c))) random_bytes; + let secret_key = Buffer.contents buf in + let dummy = "0000000000000000000000000000000000000000000000000000000000000000" in + match Bip340.sign ~secret_key ~msg:dummy with + | Ok (_, pubkey) -> (secret_key, pubkey) + | Error _ -> failwith "keypair gen failed" + +let sha_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +let sha_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +let current () = Int64.of_float (Unix.time ()) + +(* ---- report_type_of_string / report_type_to_string ---- *) +let test_report_type_roundtrip () = + let all = [ + "nudity"; "malware"; "profanity"; "illegal"; "spam"; + "impersonation"; "other"; + ] in + List.iter (fun s -> + match Report.report_type_of_string s with + | None -> failf "expected Some for %s" s + | Some rt -> + check string ("roundtrip " ^ s) s (Report.report_type_to_string rt) + ) all + +let test_report_type_unknown () = + check bool "unknown type rejected" true + (Report.report_type_of_string "unknown" = None); + check bool "empty string rejected" true + (Report.report_type_of_string "" = None) + +(* ---- validate happy path ---- *) +let test_validate_single_x_tag () = + let secret_key, pubkey = fresh_keypair () in + let created_at = Int64.sub (current ()) 10L in + let body = make_report_json + ~secret_key ~pubkey ~created_at + ~tags:[["x"; sha_a; "malware"]] + ~content:"contains a virus" + in + match Report.validate ~current_time:(current ()) body with + | Error _ -> fail "expected validation success" + | Ok r -> + check string "reporter pubkey" pubkey r.reporter_pubkey; + check int "one entry" 1 (List.length r.entries); + let entry = List.hd r.entries in + check string "sha256" sha_a entry.sha256; + check string "report type" "malware" (Report.report_type_to_string entry.report_type); + check string "content" "contains a virus" r.content; + check (option string) "no e_tag" None r.e_tag; + check (option string) "no p_tag" None r.p_tag + +let test_validate_multiple_x_tags () = + let secret_key, pubkey = fresh_keypair () in + let created_at = current () in + let body = make_report_json + ~secret_key ~pubkey ~created_at + ~tags:[ + ["x"; sha_a; "illegal"]; + ["x"; sha_b; "spam"]; + ["e"; "deadbeef"]; + ["p"; "feedface"]; + ] + ~content:"" + in + match Report.validate ~current_time:(current ()) body with + | Error _ -> fail "expected success" + | Ok r -> + check int "two entries" 2 (List.length r.entries); + check (option string) "e_tag preserved" (Some "deadbeef") r.e_tag; + check (option string) "p_tag preserved" (Some "feedface") r.p_tag + +(* ---- validate rejects bad input ---- *) +let test_validate_wrong_kind () = + let secret_key, pubkey = fresh_keypair () in + let kind = 1 in + let created_at = current () in + let tags = [["x"; sha_a; "malware"]] in + let id = Nostr_event.compute_id ~pubkey ~created_at ~kind ~tags ~content:"" in + let sig_ = + match Bip340.sign ~secret_key ~msg:id with + | Ok (s, _) -> s | Error _ -> failwith "sign" + in + let body = + `Assoc [ + ("id", `String id); + ("pubkey", `String pubkey); + ("created_at", `Int (Int64.to_int created_at)); + ("kind", `Int kind); + ("tags", `List [`List [`String "x"; `String sha_a; `String "malware"]]); + ("content", `String ""); + ("sig", `String sig_); + ] |> Yojson.Safe.to_string + in + match Report.validate ~current_time:(current ()) body with + | Ok _ -> fail "expected failure" + | Error (Domain.Report_error msg) -> + check bool "kind error message" true (String.length msg > 0) + | Error _ -> fail "expected Report_error" + +let test_validate_no_x_tag () = + let secret_key, pubkey = fresh_keypair () in + let body = make_report_json + ~secret_key ~pubkey ~created_at:(current ()) + ~tags:[["e"; "deadbeef"]] + ~content:"" + in + match Report.validate ~current_time:(current ()) body with + | Ok _ -> fail "expected failure" + | Error (Domain.Report_error _) -> () + | Error _ -> fail "expected Report_error" + +let test_validate_invalid_sha256 () = + let secret_key, pubkey = fresh_keypair () in + let body = make_report_json + ~secret_key ~pubkey ~created_at:(current ()) + ~tags:[["x"; "not-a-sha256"; "malware"]] + ~content:"" + in + match Report.validate ~current_time:(current ()) body with + | Ok _ -> fail "expected failure" + | Error (Domain.Report_error _) -> () + | Error _ -> fail "expected Report_error" + +let test_validate_invalid_report_type () = + let secret_key, pubkey = fresh_keypair () in + let body = make_report_json + ~secret_key ~pubkey ~created_at:(current ()) + ~tags:[["x"; sha_a; "vandalism"]] + ~content:"" + in + match Report.validate ~current_time:(current ()) body with + | Ok _ -> fail "expected failure" + | Error (Domain.Report_error _) -> () + | Error _ -> fail "expected Report_error" + +let test_validate_x_tag_missing_type () = + let secret_key, pubkey = fresh_keypair () in + let body = make_report_json + ~secret_key ~pubkey ~created_at:(current ()) + ~tags:[["x"; sha_a]] + ~content:"" + in + match Report.validate ~current_time:(current ()) body with + | Ok _ -> fail "expected failure" + | Error (Domain.Report_error _) -> () + | Error _ -> fail "expected Report_error" + +let test_validate_tampered_signature () = + let secret_key, pubkey = fresh_keypair () in + let body = make_report_json + ~secret_key ~pubkey ~created_at:(current ()) + ~tags:[["x"; sha_a; "spam"]] + ~content:"hi" + in + (* Replace the content after signing so id will mismatch *) + let json = Yojson.Safe.from_string body in + let tampered = + match json with + | `Assoc fields -> + let fields = List.map (fun (k, v) -> + if k = "content" then (k, `String "tampered") else (k, v) + ) fields in + Yojson.Safe.to_string (`Assoc fields) + | _ -> failwith "expected object" + in + match Report.validate ~current_time:(current ()) tampered with + | Ok _ -> fail "expected failure" + | Error (Domain.Report_error _) -> () + | Error _ -> fail "expected Report_error" + +let test_validate_malformed_json () = + match Report.validate ~current_time:(current ()) "not-json" with + | Ok _ -> fail "expected failure" + | Error (Domain.Report_error _) -> () + | Error _ -> fail "expected Report_error" + +let tests = [ + test_case "report_type roundtrip" `Quick test_report_type_roundtrip; + test_case "report_type unknown" `Quick test_report_type_unknown; + test_case "validate single x tag" `Quick test_validate_single_x_tag; + test_case "validate multiple x tags + e/p" `Quick test_validate_multiple_x_tags; + test_case "validate rejects wrong kind" `Quick test_validate_wrong_kind; + test_case "validate rejects no x tag" `Quick test_validate_no_x_tag; + test_case "validate rejects invalid sha256" `Quick test_validate_invalid_sha256; + test_case "validate rejects invalid report type" `Quick test_validate_invalid_report_type; + test_case "validate rejects x tag without type" `Quick test_validate_x_tag_missing_type; + test_case "validate rejects tampered signature" `Quick test_validate_tampered_signature; + test_case "validate rejects malformed JSON" `Quick test_validate_malformed_json; +] diff --git a/test/test_runner.ml b/test/test_runner.ml index fc1ffc0..4b5bf72 100644 --- a/test/test_runner.ml +++ b/test/test_runner.ml @@ -11,4 +11,5 @@ let () = "Nostr_event", Test_nostr_event.tests; "Nostr_signer", Test_nostr_signer.tests; "Mirror", Test_mirror.tests; + "Report", Test_report.tests; ]