777 lines
35 KiB
Python
Executable File
777 lines
35 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os
|
|
import random
|
|
import sys
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
sys.path.append(os.path.join(os.path.dirname(__file__), "checklib"))
|
|
|
|
from checklib import ( # type: ignore
|
|
BaseChecker,
|
|
Status,
|
|
cquit,
|
|
rnd_password,
|
|
rnd_string,
|
|
rnd_username,
|
|
)
|
|
import requests
|
|
|
|
|
|
class PolyphoniaChecker(BaseChecker):
|
|
vulns = 1
|
|
timeout = 10
|
|
|
|
def __init__(self, host: str):
|
|
super().__init__(host)
|
|
self._base_url = self._build_base_url()
|
|
|
|
# ========== public API required by BaseChecker ==========
|
|
def check(self):
|
|
"""Perform a realistic user flow with varied actions.
|
|
|
|
Adds human-like behavior: listing tones, creating/editing/deleting
|
|
tones and melodies, and verifying backend state using checklib asserts.
|
|
"""
|
|
username, password = self._credentials()
|
|
|
|
# Prepare a target melody we will later verify
|
|
target_melody_name = self._humanish_name(unique=True)
|
|
target_description = self._humanish_description()
|
|
|
|
session = self.get_initialized_session()
|
|
try:
|
|
self._register(session, username, password)
|
|
self._login(session, username, password)
|
|
|
|
# 1) Tone library lifecycle: list -> create -> verify -> edit -> verify -> delete -> verify
|
|
tones_initial = self._list_tones(session)
|
|
self.assert_eq(len(tones_initial), 0, "New user must have empty tone library")
|
|
|
|
# Create 2-3 tones
|
|
created_tones: List[Dict[str, Any]] = []
|
|
for _ in range(random.randint(2, 3)):
|
|
tone_payload = self._tone_sequence_payload(include_name=True)
|
|
tone_id = self._save_tone_sequence(session, tone_payload)
|
|
tone_payload_copy = dict(tone_payload)
|
|
tone_payload_copy["id"] = tone_id
|
|
created_tones.append(tone_payload_copy)
|
|
|
|
# Verify created tones via listing
|
|
tones_after_create = self._list_tones(session)
|
|
for t in created_tones:
|
|
found = self._find_tone_by_name(tones_after_create, t["name"])
|
|
self.assert_(found is not None, f"Tone not found after create: {t['name']}")
|
|
self._assert_tone_matches_expected(t, found, "Tone mismatch after create")
|
|
|
|
# Edit the second tone (if exists) by re-saving with same name and changed fields
|
|
if len(created_tones) >= 2:
|
|
original = created_tones[1]
|
|
updated = dict(original)
|
|
# Change a few musically-reasonable fields
|
|
updated["tempo"] = random.choice([100, 110, 128, 140])
|
|
updated["duration"] = random.choice([1, 2, 3, 4])
|
|
updated["chordType"] = random.choice(["major", "minor", "sus2", "none"])
|
|
updated["intervalType"] = random.choice(["unison", "third", "fifth"])
|
|
# Rebuild notes a bit
|
|
bn = updated["baseNote"] = max(36, min(84, updated.get("baseNote", 60) + random.choice([-2, -1, 1, 2])))
|
|
updated["notes"] = [bn, bn + (3 if updated["chordType"] == "minor" else 4), bn + 7]
|
|
|
|
# Save with same name to trigger upsert
|
|
payload_for_update = {k: updated[k] for k in [
|
|
"name", "description", "baseNote", "intervalType", "chordType", "tempo", "duration", "notes"
|
|
]}
|
|
tone_id_after = self._update_tone_sequence(session, payload_for_update)
|
|
self.assert_eq(tone_id_after, original["id"], "Tone ID changed after update")
|
|
|
|
# Verify the update via listing
|
|
tones_after_update = self._list_tones(session)
|
|
found = self._find_tone_by_name(tones_after_update, updated["name"])
|
|
self.assert_(found is not None, "Updated tone not found in list")
|
|
self._assert_tone_matches_expected(updated, found, "Tone mismatch after update")
|
|
|
|
# Update our local state
|
|
created_tones[1] = updated
|
|
|
|
# Delete the first tone and verify it's gone
|
|
to_delete = created_tones[0]
|
|
self._delete_tone(session, to_delete["id"])
|
|
tones_after_delete = self._list_tones(session)
|
|
self.assert_(self._find_tone_by_name(tones_after_delete, to_delete["name"]) is None, "Tone still present after delete")
|
|
# Keep the expected list in sync
|
|
created_tones = [t for t in created_tones if t["id"] != to_delete["id"]]
|
|
|
|
# Additional messy randomized tone ops with checks after each
|
|
ops = random.randint(2, 4)
|
|
for _ in range(ops):
|
|
action = random.choice(["create", "edit", "delete", "noop"])
|
|
if action == "create":
|
|
p = self._tone_sequence_payload(include_name=True)
|
|
tid = self._save_tone_sequence(session, p)
|
|
pcopy = dict(p)
|
|
pcopy["id"] = tid
|
|
created_tones.append(pcopy)
|
|
elif action == "edit" and created_tones:
|
|
idx = random.randrange(len(created_tones))
|
|
original = created_tones[idx]
|
|
updated = dict(original)
|
|
updated["tempo"] = random.choice([90, 110, 120, 140])
|
|
updated["duration"] = random.choice([1, 2, 3, 4])
|
|
# Nudge base note slightly and rebuild notes (triad)
|
|
bn = updated["baseNote"] = max(36, min(84, updated.get("baseNote", 60) + random.choice([-2, 0, 2])))
|
|
updated["notes"] = [bn, bn + (3 if updated.get("chordType") == "minor" else 4), bn + 7]
|
|
payload_for_update = {k: updated[k] for k in [
|
|
"name", "description", "baseNote", "intervalType", "chordType", "tempo", "duration", "notes"
|
|
]}
|
|
tid_after = self._update_tone_sequence(session, payload_for_update)
|
|
self.assert_eq(tid_after, original["id"], "Tone ID changed after random edit")
|
|
created_tones[idx] = updated
|
|
elif action == "delete" and created_tones:
|
|
idx = random.randrange(len(created_tones))
|
|
victim = created_tones.pop(idx)
|
|
self._delete_tone(session, victim["id"])
|
|
# After each action, list and verify full set matches expectation
|
|
listed_now = self._list_tones(session)
|
|
self._assert_tone_sets_match(created_tones, listed_now, "Tone set mismatch after op")
|
|
|
|
# 2) Create multiple melodies with more variety, then edit+delete one
|
|
total_melodies = random.randint(2, 4)
|
|
idx_target = random.randrange(total_melodies)
|
|
melodies_expected: Dict[str, Dict[str, Any]] = {}
|
|
for i in range(total_melodies):
|
|
if i == idx_target:
|
|
name = target_melody_name
|
|
description = target_description
|
|
else:
|
|
name = self._humanish_name(unique=True)
|
|
description = self._humanish_description()
|
|
|
|
seq_count = random.randint(1, 4)
|
|
seqs = [self._tone_sequence_payload(include_name=False) for _ in range(seq_count)]
|
|
mid = self._create_melody(session, name, description, tone_sequences=seqs)
|
|
melodies_expected[name] = {"id": mid, "description": description}
|
|
|
|
# Edit a non-target melody if possible: change its description and sequences
|
|
editable = [n for n in melodies_expected.keys() if n != target_melody_name]
|
|
if editable:
|
|
mname = random.choice(editable)
|
|
new_desc = self._humanish_description()
|
|
new_seqs = [self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))]
|
|
mid_before = melodies_expected[mname]["id"]
|
|
mid_after = self._update_melody(session, mname, new_desc, tone_sequences=new_seqs)
|
|
self.assert_eq(mid_after, mid_before, "Melody ID changed after update")
|
|
melodies_expected[mname]["description"] = new_desc
|
|
|
|
# 3) Basic profile + library checks
|
|
profile = self._get_profile(session)
|
|
self.assert_eq(profile.get("username"), username, "Broken user info")
|
|
melodies = self._list_melodies(session)
|
|
melody = self._find_melody(melodies, target_melody_name)
|
|
self.assert_(melody is not None, "Saved melody not found")
|
|
self.assert_eq(melody.get("description"), target_description, "Melody description mismatch")
|
|
|
|
# 4) Optionally delete a non-target melody and verify
|
|
if editable:
|
|
del_name = mname
|
|
del_id = melodies_expected[del_name]["id"]
|
|
self._delete_melody(session, del_id)
|
|
melodies_after_del = self._list_melodies(session)
|
|
self.assert_(self._find_melody(melodies_after_del, del_name) is None, "Melody still present after delete")
|
|
melodies_expected.pop(del_name, None)
|
|
|
|
# 5) Do a few messy randomized melody ops with checks after each
|
|
more_ops = random.randint(1, 3)
|
|
for _ in range(more_ops):
|
|
op = random.choice(["create", "edit", "delete", "noop"])
|
|
current_names = list(melodies_expected.keys())
|
|
non_target = [n for n in current_names if n != target_melody_name]
|
|
if op == "create":
|
|
n = self._humanish_name(unique=True)
|
|
d = self._humanish_description()
|
|
seqs = [self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))]
|
|
mid = self._create_melody(session, n, d, tone_sequences=seqs)
|
|
melodies_expected[n] = {"id": mid, "description": d}
|
|
elif op == "edit" and non_target:
|
|
n = random.choice(non_target)
|
|
d = self._humanish_description()
|
|
seqs = [self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))]
|
|
before_id = melodies_expected[n]["id"]
|
|
after_id = self._update_melody(session, n, d, tone_sequences=seqs)
|
|
self.assert_eq(after_id, before_id, "Melody ID changed after edit")
|
|
melodies_expected[n]["description"] = d
|
|
elif op == "delete" and non_target:
|
|
n = random.choice(non_target)
|
|
self._delete_melody(session, melodies_expected[n]["id"])
|
|
melodies_expected.pop(n, None)
|
|
# After each op, list and verify set matches expected
|
|
listed = self._list_melodies(session)
|
|
self._assert_melody_sets_match_expected(melodies_expected, listed, "Melody set mismatch after op")
|
|
except requests.RequestException as exc:
|
|
self.cquit(Status.DOWN, "Connection error", str(exc))
|
|
|
|
self.cquit(Status.OK)
|
|
|
|
def put(self, flag_id: str, flag: str, vuln: str):
|
|
"""Store the flag while doing human-like noisy actions, but never delete it."""
|
|
username, password = self._credentials()
|
|
|
|
# Use a human-like unique name for the flagged melody
|
|
melody_name = self._humanish_name(unique=True)
|
|
# Also create a flagged tone to add realism (not used for get, but kept alive)
|
|
flagged_tone_name = self._humanish_name(unique=True)
|
|
|
|
session = self.get_initialized_session()
|
|
try:
|
|
self._register(session, username, password)
|
|
self._login(session, username, password)
|
|
|
|
# Tone noise: create some tones, including a flagged tone we protect
|
|
flagged_tone_payload = self._tone_sequence_payload(include_name=True)
|
|
flagged_tone_payload["name"] = flagged_tone_name
|
|
flagged_tone_payload["description"] = self._humanish_description(extra=flag)
|
|
flagged_tone_id = self._save_tone_sequence(session, flagged_tone_payload)
|
|
|
|
tone_ids: List[Tuple[int, str]] = [(flagged_tone_id, flagged_tone_name)]
|
|
for _ in range(random.randint(1, 2)):
|
|
p = self._tone_sequence_payload(include_name=True)
|
|
tid = self._save_tone_sequence(session, p)
|
|
tone_ids.append((tid, p["name"]))
|
|
|
|
# Ensure at least one tone update (never touch the flagged tone's name)
|
|
non_flag = [t for t in tone_ids if t[0] != flagged_tone_id]
|
|
if not non_flag:
|
|
# Create one extra to edit
|
|
p = self._tone_sequence_payload(include_name=True)
|
|
tid = self._save_tone_sequence(session, p)
|
|
tone_ids.append((tid, p["name"]))
|
|
non_flag = [(tid, p["name"])]
|
|
tid_edit, name_edit = random.choice(non_flag)
|
|
upd_once = self._tone_sequence_payload(include_name=True)
|
|
upd_once["name"] = name_edit
|
|
self._update_tone_sequence(session, upd_once)
|
|
|
|
# Random tone edits/deletes but never touch the flagged tone
|
|
for _ in range(random.randint(1, 3)):
|
|
action = random.choice(["create", "edit", "delete", "noop"])
|
|
if action == "create":
|
|
self._save_tone_sequence(session, self._tone_sequence_payload(include_name=True))
|
|
elif action == "edit" and len(tone_ids) > 0:
|
|
# pick a non-flag tone to edit
|
|
candidates = [t for t in tone_ids if t[0] != flagged_tone_id]
|
|
if candidates:
|
|
tid, tname = random.choice(candidates)
|
|
upd = self._tone_sequence_payload(include_name=True)
|
|
upd["name"] = tname
|
|
self._update_tone_sequence(session, upd)
|
|
elif action == "delete" and len(tone_ids) > 1:
|
|
# delete a non-flag tone
|
|
candidates = [t for t in tone_ids if t[0] != flagged_tone_id]
|
|
if candidates:
|
|
tid, tname = candidates[0]
|
|
self._delete_tone(session, tid)
|
|
tone_ids = [(i, n) for (i, n) in tone_ids if i != tid]
|
|
|
|
# Verify flagged tone persists and contains flag
|
|
tones_list = self._list_tones(session)
|
|
ft = self._find_tone_by_name(tones_list, flagged_tone_name)
|
|
self.assert_(ft is not None, "Flagged tone missing after noise")
|
|
self.assert_in(flag, ft.get("description", ""), "Flag missing from flagged tone")
|
|
|
|
# Melody noise: create some melodies first
|
|
for _ in range(random.randint(1, 2)):
|
|
self._create_melody(
|
|
session,
|
|
self._humanish_name(unique=True),
|
|
self._humanish_description(),
|
|
tone_sequences=[self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))],
|
|
)
|
|
|
|
# Create the flagged melody (flag appended in description)
|
|
seqs = [self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))]
|
|
flagged_melody_id = self._create_melody(
|
|
session, melody_name, self._humanish_description(extra=flag), tone_sequences=seqs
|
|
)
|
|
|
|
# More melody operations that never delete or rename the flagged melody
|
|
for _ in range(random.randint(1, 3)):
|
|
op = random.choice(["create", "edit", "delete", "noop"])
|
|
if op == "create":
|
|
self._create_melody(
|
|
session,
|
|
self._humanish_name(unique=True),
|
|
self._humanish_description(),
|
|
tone_sequences=[self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))],
|
|
)
|
|
elif op == "edit":
|
|
# edit a random melody that isn't the flagged one
|
|
mlist = self._list_melodies(session)
|
|
others = [m for m in mlist if m.get("id") != flagged_melody_id and isinstance(m.get("name"), str)]
|
|
if others:
|
|
target = random.choice(others)
|
|
self._update_melody(
|
|
session,
|
|
target.get("name", self._humanish_name(unique=True)),
|
|
self._humanish_description(),
|
|
tone_sequences=[self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))],
|
|
)
|
|
elif op == "delete":
|
|
mlist = self._list_melodies(session)
|
|
victims = [m for m in mlist if m.get("id") != flagged_melody_id]
|
|
if victims:
|
|
self._delete_melody(session, victims[0].get("id"))
|
|
|
|
# Ensure flagged melody remains and still contains the flag
|
|
melodies_now = self._list_melodies(session)
|
|
flagged = self._find_melody(melodies_now, melody_name)
|
|
self.assert_(flagged is not None, "Flagged melody missing after noise")
|
|
self.assert_in(flag, flagged.get("description", ""), "Flag missing from flagged melody")
|
|
except requests.RequestException as exc:
|
|
self.cquit(Status.DOWN, "Connection error", str(exc))
|
|
|
|
self.cquit(Status.OK, f"{username}:{melody_name}", f"{username}:{password}:{melody_name}")
|
|
|
|
def get(self, flag_id: str, flag: str, vuln: str):
|
|
try:
|
|
username, password, melody_name = self._parse_flag_id(flag_id)
|
|
except ValueError:
|
|
self.cquit(Status.CORRUPT, "Invalid flag id", flag_id)
|
|
|
|
session = self.get_initialized_session()
|
|
try:
|
|
self._login(session, username, password)
|
|
# Ensure at least one tone update in get()
|
|
tones_exist = self._list_tones(session)
|
|
if not tones_exist:
|
|
self._save_tone_sequence(session, self._tone_sequence_payload(include_name=True))
|
|
tones_exist = self._list_tones(session)
|
|
t_edit = random.choice(tones_exist)
|
|
up = self._tone_sequence_payload(include_name=True)
|
|
up["name"] = t_edit.get("name", self._humanish_name(unique=True))
|
|
self._update_tone_sequence(session, up)
|
|
|
|
# Add some noise: random tone/melody ops that never touch the flagged melody
|
|
for _ in range(random.randint(1, 3)):
|
|
# tones
|
|
action = random.choice(["tone_create", "tone_edit", "tone_delete", "noop"])
|
|
if action == "tone_create":
|
|
self._save_tone_sequence(session, self._tone_sequence_payload(include_name=True))
|
|
elif action == "tone_edit":
|
|
tones = self._list_tones(session)
|
|
if tones:
|
|
t = random.choice(tones)
|
|
upd = self._tone_sequence_payload(include_name=True)
|
|
upd["name"] = t.get("name", self._humanish_name(unique=True))
|
|
self._update_tone_sequence(session, upd)
|
|
elif action == "tone_delete":
|
|
tones = self._list_tones(session)
|
|
if tones:
|
|
self._delete_tone(session, tones[0].get("id"))
|
|
|
|
for _ in range(random.randint(1, 2)):
|
|
# melodies
|
|
op = random.choice(["mel_create", "mel_edit", "mel_delete", "noop"])
|
|
if op == "mel_create":
|
|
self._create_melody(
|
|
session,
|
|
self._humanish_name(unique=True),
|
|
self._humanish_description(),
|
|
tone_sequences=[self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))],
|
|
)
|
|
elif op == "mel_edit":
|
|
mlist = self._list_melodies(session)
|
|
others = [m for m in mlist if m.get("name") != melody_name]
|
|
if others:
|
|
m = random.choice(others)
|
|
self._update_melody(
|
|
session,
|
|
m.get("name", self._humanish_name(unique=True)),
|
|
self._humanish_description(),
|
|
tone_sequences=[self._tone_sequence_payload(include_name=False) for _ in range(random.randint(1, 3))],
|
|
)
|
|
elif op == "mel_delete":
|
|
mlist = self._list_melodies(session)
|
|
victims = [m for m in mlist if m.get("name") != melody_name]
|
|
if victims:
|
|
self._delete_melody(session, victims[0].get("id"))
|
|
|
|
melodies = self._list_melodies(session)
|
|
melody = self._find_melody(melodies, melody_name)
|
|
except requests.RequestException as exc:
|
|
self.cquit(Status.DOWN, "Connection error", str(exc))
|
|
|
|
if melody is None:
|
|
self.cquit(Status.CORRUPT, "Melody not found", melody_name)
|
|
|
|
description = melody.get("description")
|
|
self.assert_(isinstance(description, str), "Invalid melody description type")
|
|
self.assert_in(flag, description, "Flag not in melody")
|
|
self.cquit(Status.OK)
|
|
|
|
# ========== helper methods ==========
|
|
def _build_base_url(self) -> str:
|
|
host = self.host.rstrip('/')
|
|
if host.startswith("http://") or host.startswith("https://"):
|
|
return host
|
|
|
|
if ":" in host:
|
|
return f"http://{host}"
|
|
|
|
port_env = os.environ.get("POLYPHONIA_PORT")
|
|
try:
|
|
port = int(port_env) if port_env else 22025
|
|
except ValueError:
|
|
port = 3000
|
|
|
|
if port == 80:
|
|
return f"http://{host}"
|
|
return f"http://{host}:{port}"
|
|
|
|
def _url(self, path: str) -> str:
|
|
if not path.startswith("/"):
|
|
path = f"/{path}"
|
|
return f"{self._base_url}{path}"
|
|
|
|
def _credentials(self) -> Tuple[str, str]:
|
|
username = rnd_username()
|
|
password = rnd_password()
|
|
return username, password
|
|
|
|
def _random_name(self) -> str:
|
|
return rnd_string(12)
|
|
|
|
def _random_note(self) -> str:
|
|
return rnd_string(16)
|
|
|
|
# ----- HTTP helpers -----
|
|
def _register(self, session: requests.Session, username: str, password: str) -> None:
|
|
resp = session.post(
|
|
self._url("/api/register"),
|
|
json={"username": username, "password": password},
|
|
timeout=self.timeout,
|
|
)
|
|
self.check_response(resp, "Registration failed")
|
|
data = self.get_json(resp, "Invalid register response")
|
|
self.assert_(data.get("success"), "Registration rejects request")
|
|
|
|
def _login(self, session: requests.Session, username: str, password: str) -> None:
|
|
resp = session.post(
|
|
self._url("/api/login"),
|
|
json={"username": username, "password": password},
|
|
timeout=self.timeout,
|
|
)
|
|
self.check_response(resp, "Login failed")
|
|
data = self.get_json(resp, "Invalid login response")
|
|
self.assert_(data.get("success"), "Login rejects credentials")
|
|
|
|
def _create_melody(
|
|
self,
|
|
session: requests.Session,
|
|
name: str,
|
|
description: str,
|
|
tone_sequences: Optional[List[Dict[str, Any]]] = None,
|
|
) -> int:
|
|
if tone_sequences is None:
|
|
tone_sequences = [self._tone_sequence_payload(include_name=False)]
|
|
payload = {
|
|
"name": name,
|
|
"description": description,
|
|
"toneSequences": tone_sequences,
|
|
}
|
|
resp = session.post(
|
|
self._url("/api/melodies"),
|
|
json=payload,
|
|
timeout=self.timeout,
|
|
)
|
|
self.check_response(resp, "Melody save failed")
|
|
data = self.get_json(resp, "Invalid melody save response")
|
|
self.assert_(data.get("success"), "Melody not accepted")
|
|
melody_id = data.get("id")
|
|
self.assert_(isinstance(melody_id, int) and melody_id > 0, "Invalid melody id")
|
|
return melody_id
|
|
|
|
def _update_melody(
|
|
self,
|
|
session: requests.Session,
|
|
name: str,
|
|
description: str,
|
|
tone_sequences: Optional[List[Dict[str, Any]]] = None,
|
|
) -> int:
|
|
if tone_sequences is None:
|
|
tone_sequences = [self._tone_sequence_payload(include_name=False)]
|
|
payload = {
|
|
"name": name,
|
|
"description": description,
|
|
"toneSequences": tone_sequences,
|
|
}
|
|
resp = session.put(
|
|
self._url("/api/melodies"),
|
|
json=payload,
|
|
timeout=self.timeout,
|
|
)
|
|
self.check_response(resp, "Melody update failed")
|
|
data = self.get_json(resp, "Invalid melody update response")
|
|
self.assert_(data.get("success"), "Melody update not accepted")
|
|
melody_id = data.get("id")
|
|
self.assert_(isinstance(melody_id, int) and melody_id > 0, "Invalid melody id after update")
|
|
return melody_id
|
|
|
|
def _delete_melody(self, session: requests.Session, melody_id: int) -> None:
|
|
resp = session.delete(self._url(f"/api/melodies/{melody_id}"), timeout=self.timeout)
|
|
self.check_response(resp, "Melody delete failed")
|
|
data = self.get_json(resp, "Invalid melody delete response")
|
|
self.assert_(data.get("success"), "Melody delete not accepted")
|
|
|
|
def _get_profile(self, session: requests.Session) -> Dict[str, Any]:
|
|
resp = session.get(self._url("/api/user"), timeout=self.timeout)
|
|
self.check_response(resp, "User profile failed")
|
|
data = self.get_json(resp, "Invalid user response")
|
|
self.assert_("username" in data, "Username missing in profile")
|
|
return data
|
|
|
|
def _list_melodies(self, session: requests.Session) -> List[Dict[str, Any]]:
|
|
resp = session.get(self._url("/api/melodies"), timeout=self.timeout)
|
|
self.check_response(resp, "Melody list failed")
|
|
data = self.get_json(resp, "Invalid melody list")
|
|
melodies = data.get("melodies")
|
|
self.assert_(isinstance(melodies, list), "Melody list is not a list")
|
|
result: List[Dict[str, Any]] = []
|
|
for item in melodies:
|
|
if isinstance(item, dict):
|
|
result.append(item)
|
|
return result
|
|
|
|
def _find_melody(self, melodies: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
|
|
for item in melodies:
|
|
if item.get("name") == name:
|
|
return item
|
|
return None
|
|
|
|
def _tone_sequence_payload(self, include_name: bool = False) -> Dict[str, Any]:
|
|
base_note = random.randint(48, 72)
|
|
tempo = random.choice([90, 100, 110, 120, 128, 140, 150])
|
|
duration = random.choice([1, 2, 2, 3, 4])
|
|
interval_type = random.choice(["unison", "third", "fifth", "octave", "seventh"])
|
|
chord_type = random.choice(["none", "major", "minor", "sus2", "sus4", "dim"])
|
|
|
|
mode = random.choice(["triad", "arp", "scale"]) # type: ignore
|
|
if mode == "triad":
|
|
if chord_type == "minor":
|
|
notes = [base_note, base_note + 3, base_note + 7]
|
|
elif chord_type == "dim":
|
|
notes = [base_note, base_note + 3, base_note + 6]
|
|
else:
|
|
notes = [base_note, base_note + 4, base_note + 7]
|
|
elif mode == "arp":
|
|
step = random.choice([2, 3, 4])
|
|
notes = [base_note + i * step for i in range(3)]
|
|
else:
|
|
scale = random.choice([[2, 2, 1, 2], [2, 1, 2, 2], [1, 2, 2, 2]])
|
|
notes = [base_note]
|
|
cur = base_note
|
|
for s in scale:
|
|
cur += s
|
|
notes.append(cur)
|
|
|
|
payload: Dict[str, Any] = {
|
|
"id": random.randint(1, 1_000_000),
|
|
"baseNote": base_note,
|
|
"intervalType": interval_type,
|
|
"chordType": chord_type,
|
|
"tempo": tempo,
|
|
"duration": duration,
|
|
"notes": notes,
|
|
}
|
|
if include_name:
|
|
payload["name"] = self._humanish_name(unique=True)
|
|
payload["description"] = self._humanish_description()
|
|
return payload
|
|
|
|
# ----- Tone library helpers -----
|
|
def _list_tones(self, session: requests.Session) -> List[Dict[str, Any]]:
|
|
resp = session.get(self._url("/api/tones"), timeout=self.timeout)
|
|
self.check_response(resp, "Tone list failed")
|
|
data = self.get_json(resp, "Invalid tone list")
|
|
tones = data.get("tones")
|
|
self.assert_(isinstance(tones, list), "Tone list is not a list")
|
|
result: List[Dict[str, Any]] = []
|
|
for item in tones:
|
|
if isinstance(item, dict):
|
|
result.append(item)
|
|
return result
|
|
|
|
def _find_tone_by_name(self, tones: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
|
|
for item in tones:
|
|
if item.get("name") == name:
|
|
return item
|
|
return None
|
|
|
|
def _delete_tone(self, session: requests.Session, tone_id: int) -> None:
|
|
resp = session.delete(self._url(f"/api/tones/{tone_id}"), timeout=self.timeout)
|
|
self.check_response(resp, "Tone delete failed")
|
|
data = self.get_json(resp, "Invalid tone delete response")
|
|
self.assert_(data.get("success"), "Tone delete not accepted")
|
|
|
|
def _assert_tone_matches_expected(self, expected: Dict[str, Any], actual: Dict[str, Any], msg: str) -> None:
|
|
# Only compare relevant musical fields
|
|
keys = ["name", "description", "baseNote", "intervalType", "chordType", "tempo", "duration", "notes"]
|
|
for k in keys:
|
|
self.assert_(k in actual, f"{msg}: key missing {k}")
|
|
self.assert_eq(actual[k], expected[k], f"{msg}: field mismatch {k}")
|
|
|
|
def _assert_tone_sets_match(self, expected_list: List[Dict[str, Any]], actual_list: List[Dict[str, Any]], msg: str) -> None:
|
|
exp_names = {t["name"] for t in expected_list}
|
|
act_names = {t.get("name") for t in actual_list}
|
|
self.assert_eq(act_names, exp_names, f"{msg}: name sets differ")
|
|
for e in expected_list:
|
|
a = self._find_tone_by_name(actual_list, e["name"])
|
|
self.assert_(a is not None, f"{msg}: missing tone {e['name']}")
|
|
self._assert_tone_matches_expected(e, a, msg)
|
|
|
|
def _assert_melody_sets_match_expected(self, expected: Dict[str, Dict[str, Any]], actual_list: List[Dict[str, Any]], msg: str) -> None:
|
|
exp_names = set(expected.keys())
|
|
act_names = {m.get("name") for m in actual_list}
|
|
self.assert_eq(act_names, exp_names, f"{msg}: name sets differ")
|
|
for name, meta in expected.items():
|
|
m = self._find_melody(actual_list, name)
|
|
self.assert_(m is not None, f"{msg}: missing melody {name}")
|
|
self.assert_eq(m.get("id"), meta.get("id"), f"{msg}: id mismatch for {name}")
|
|
self.assert_eq(m.get("description"), meta.get("description"), f"{msg}: description mismatch for {name}")
|
|
|
|
# ----- Human-like content generators -----
|
|
def _unique_tail(self) -> str:
|
|
return os.urandom(6).hex()
|
|
|
|
def _humanish_name(self, unique: bool = False) -> str:
|
|
adjectives = [
|
|
"Midnight",
|
|
"Autumn",
|
|
"Silent",
|
|
"Velvet",
|
|
"Crimson",
|
|
"Electric",
|
|
"Gentle",
|
|
"Bittersweet",
|
|
"Neon",
|
|
"Emerald",
|
|
"Abyss",
|
|
"Golden",
|
|
"Shimmering",
|
|
"Dusky",
|
|
"Azure",
|
|
"Iridescent",
|
|
"Secret",
|
|
"Distant",
|
|
"Frosted",
|
|
"Lunar",
|
|
"Amber",
|
|
"Cobalt",
|
|
]
|
|
nouns = [
|
|
"Echo",
|
|
"Nocturne",
|
|
"Pulse",
|
|
"Drift",
|
|
"Waltz",
|
|
"Mirage",
|
|
"Breeze",
|
|
"Canvas",
|
|
"Cascade",
|
|
"Lullaby",
|
|
"Horizon",
|
|
"Reverie",
|
|
"Serenade",
|
|
"Voyage",
|
|
"Haze",
|
|
"Sketch",
|
|
"Pattern",
|
|
"Study",
|
|
"Ritual",
|
|
"Bloom",
|
|
"Signal",
|
|
]
|
|
variants = [
|
|
" in C minor",
|
|
" in D major",
|
|
" in A minor",
|
|
" (solo)",
|
|
" (duet)",
|
|
" (live)",
|
|
" (ambient)",
|
|
" (demo)",
|
|
"",
|
|
]
|
|
base = f"{random.choice(adjectives)} {random.choice(nouns)}{random.choice(variants)}"
|
|
if unique:
|
|
base += f" - take {self._unique_tail()}"
|
|
return base
|
|
|
|
def _humanish_description(self, extra: Optional[str] = None) -> str:
|
|
moods = [
|
|
"warm and airy",
|
|
"moody but hopeful",
|
|
"gentle and reflective",
|
|
"bright with a soft swing",
|
|
"lo-fi and nocturnal",
|
|
"dreamy with subtle tension",
|
|
"playful and syncopated",
|
|
]
|
|
instruments = [
|
|
"piano",
|
|
"strings",
|
|
"synth",
|
|
"bells",
|
|
"guitar",
|
|
"e-piano",
|
|
"pad",
|
|
]
|
|
phrases = [
|
|
"Recorded late at night.",
|
|
"Fits a calm intro.",
|
|
"Could loop nicely.",
|
|
"Try at 110 BPM.",
|
|
"Layer with a soft pad.",
|
|
"Needs a brighter lead.",
|
|
"First pass, keep it simple.",
|
|
"Rough sketch - refine later.",
|
|
"Minimal drums, focus on tone.",
|
|
]
|
|
base = f"A {random.choice(moods)} sketch for {random.choice(instruments)}. {random.choice(phrases)}"
|
|
if extra:
|
|
sep = random.choice([" ", " - ", " | "])
|
|
base += f"{sep}{extra}"
|
|
base += f" · take {self._unique_tail()}"
|
|
return base
|
|
|
|
# ----- Tone library helpers -----
|
|
def _save_tone_sequence(self, session: requests.Session, payload: Dict[str, Any]) -> int:
|
|
resp = session.post(self._url("/api/tones"), json=payload, timeout=self.timeout)
|
|
self.check_response(resp, "Tone save failed")
|
|
data = self.get_json(resp, "Invalid tone save response")
|
|
self.assert_(data.get("success"), "Tone not accepted")
|
|
tone_id = data.get("id")
|
|
self.assert_(isinstance(tone_id, int) and tone_id > 0, "Invalid tone id")
|
|
return tone_id
|
|
|
|
def _update_tone_sequence(self, session: requests.Session, payload: Dict[str, Any]) -> int:
|
|
resp = session.put(self._url("/api/tones"), json=payload, timeout=self.timeout)
|
|
self.check_response(resp, "Tone update failed")
|
|
data = self.get_json(resp, "Invalid tone update response")
|
|
self.assert_(data.get("success"), "Tone update not accepted")
|
|
tone_id = data.get("id")
|
|
self.assert_(isinstance(tone_id, int) and tone_id > 0, "Invalid tone id after update")
|
|
return tone_id
|
|
|
|
def _parse_flag_id(self, value: str) -> Tuple[str, str, str]:
|
|
parts = value.split(":", 2)
|
|
if len(parts) != 3:
|
|
raise ValueError("invalid flag id")
|
|
return parts[0], parts[1], parts[2]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
checker = PolyphoniaChecker(sys.argv[2])
|
|
|
|
try:
|
|
checker.action(sys.argv[1], *sys.argv[3:])
|
|
except checker.get_check_finished_exception():
|
|
cquit(Status(checker.status), checker.public, checker.private)
|