add comprehensive backend tests

This commit is contained in:
Arkadiy Kukarkin
2025-12-24 15:26:25 +01:00
parent 098d9c45c4
commit 1c8ff78006
4 changed files with 1133 additions and 0 deletions

701
api/api_test.go Normal file
View File

@@ -0,0 +1,701 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/parkan/go-hauk/config"
"github.com/parkan/go-hauk/store"
)
func testServer() (*Server, *store.Memory) {
mem := store.NewMemory()
cfg := &config.Config{
PublicURL: "https://example.com/",
MaxDuration: 86400,
MinInterval: 1,
MaxCachedPts: 3,
MaxShownPts: 100,
LinkStyle: 0,
AllowLinkReq: true,
PasswordHash: "$2a$10$LerNFYkUU3ZZrNHhamISZeDK8afdExOwDKbyTaUECDOLa1rV4iN.O", // "test"
AuthMethod: config.AuthPassword,
}
return NewServer(cfg, mem), mem
}
func postForm(srv http.Handler, path string, data url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", path, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
return w
}
func getPath(srv http.Handler, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
return w
}
func TestCreateSession(t *testing.T) {
srv, _ := testServer()
t.Run("missing fields", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{})
if !strings.Contains(w.Body.String(), "Missing data!") {
t.Errorf("expected missing data error, got: %s", w.Body.String())
}
})
t.Run("bad password", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"wrong"},
})
if !strings.Contains(w.Body.String(), "Incorrect password!") {
t.Errorf("expected password error, got: %s", w.Body.String())
}
})
t.Run("invalid duration", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"abc"},
"int": {"5"},
"pwd": {"test"},
})
if !strings.Contains(w.Body.String(), "Invalid duration!") {
t.Errorf("expected duration error, got: %s", w.Body.String())
}
})
t.Run("duration too long", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"999999"},
"int": {"5"},
"pwd": {"test"},
})
if !strings.Contains(w.Body.String(), "exceeds maximum") {
t.Errorf("expected max duration error, got: %s", w.Body.String())
}
})
t.Run("interval too short", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"0.1"},
"pwd": {"test"},
})
if !strings.Contains(w.Body.String(), "too short") {
t.Errorf("expected interval error, got: %s", w.Body.String())
}
})
t.Run("solo share", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"0"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if len(lines) < 4 || lines[0] != "OK" {
t.Fatalf("expected OK response, got: %s", w.Body.String())
}
if lines[1] == "" {
t.Error("expected session ID")
}
if !strings.HasPrefix(lines[2], "https://example.com/") {
t.Errorf("expected view link, got: %s", lines[2])
}
})
t.Run("group share", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"1"},
"nic": {"alice"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if len(lines) < 5 || lines[0] != "OK" {
t.Fatalf("expected OK response with pin, got: %s", w.Body.String())
}
// group share returns: OK, sid, url, pin, id
if lines[3] == "" {
t.Error("expected PIN for group share")
}
})
t.Run("e2e encrypted", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"0"},
"e2e": {"1"},
"salt": {"abcd1234"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if lines[0] != "OK" {
t.Errorf("expected OK, got: %s", w.Body.String())
}
})
t.Run("e2e without salt", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"0"},
"e2e": {"1"},
})
if !strings.Contains(w.Body.String(), "Missing data!") {
t.Errorf("expected error for e2e without salt, got: %s", w.Body.String())
}
})
t.Run("e2e with group disallowed", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"1"},
"nic": {"alice"},
"e2e": {"1"},
"salt": {"abcd"},
})
if !strings.Contains(w.Body.String(), "not supported for group") {
t.Errorf("expected e2e group error, got: %s", w.Body.String())
}
})
}
func TestPostLocation(t *testing.T) {
srv, mem := testServer()
// create a session first
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"0"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if lines[0] != "OK" {
t.Fatalf("setup failed: %s", w.Body.String())
}
sid := lines[1]
shareID := lines[3]
t.Run("missing fields", func(t *testing.T) {
w := postForm(srv, "/api/post.php", url.Values{})
if !strings.Contains(w.Body.String(), "Missing data!") {
t.Errorf("expected missing data, got: %s", w.Body.String())
}
})
t.Run("invalid session", func(t *testing.T) {
w := postForm(srv, "/api/post.php", url.Values{
"sid": {"invalid"},
"lat": {"51.5"},
"lon": {"-0.1"},
"time": {"1234567890"},
})
if !strings.Contains(w.Body.String(), "Session expired!") {
t.Errorf("expected session expired, got: %s", w.Body.String())
}
})
t.Run("invalid coordinates", func(t *testing.T) {
w := postForm(srv, "/api/post.php", url.Values{
"sid": {sid},
"lat": {"999"},
"lon": {"-0.1"},
"time": {"1234567890"},
})
if !strings.Contains(w.Body.String(), "Invalid location!") {
t.Errorf("expected invalid location, got: %s", w.Body.String())
}
})
t.Run("valid post", func(t *testing.T) {
w := postForm(srv, "/api/post.php", url.Values{
"sid": {sid},
"lat": {"51.5074"},
"lon": {"-0.1278"},
"time": {"1234567890.123"},
"spd": {"5.5"},
"acc": {"10"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if lines[0] != "OK" {
t.Errorf("expected OK, got: %s", w.Body.String())
}
if !strings.Contains(lines[2], shareID) {
t.Errorf("expected share ID in response, got: %s", lines[2])
}
})
t.Run("expired session", func(t *testing.T) {
// clear and don't set up session
mem.Clear()
w := postForm(srv, "/api/post.php", url.Values{
"sid": {sid},
"lat": {"51.5"},
"lon": {"-0.1"},
"time": {"1234567890"},
})
if !strings.Contains(w.Body.String(), "Session expired!") {
t.Errorf("expected expired, got: %s", w.Body.String())
}
})
}
func TestFetch(t *testing.T) {
srv, _ := testServer()
// create session and post location
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"0"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
sid := lines[1]
shareID := lines[3]
// post a location
postForm(srv, "/api/post.php", url.Values{
"sid": {sid},
"lat": {"51.5074"},
"lon": {"-0.1278"},
"time": {"1234567890.123"},
})
t.Run("missing id", func(t *testing.T) {
w := getPath(srv, "/api/fetch.php")
if !strings.Contains(w.Body.String(), "Invalid session!") {
t.Errorf("expected invalid session, got: %s", w.Body.String())
}
})
t.Run("invalid id", func(t *testing.T) {
w := getPath(srv, "/api/fetch.php?id=invalid")
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got: %d", w.Code)
}
})
t.Run("valid fetch", func(t *testing.T) {
w := getPath(srv, "/api/fetch.php?id="+shareID)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got: %d", w.Code)
}
var resp soloResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("json decode error: %v", err)
}
if resp.Type != 0 {
t.Errorf("expected type 0, got: %d", resp.Type)
}
if resp.Interval != 5 {
t.Errorf("expected interval 5, got: %f", resp.Interval)
}
if len(resp.Points) != 1 {
t.Errorf("expected 1 point, got: %d", len(resp.Points))
}
if len(resp.Points) > 0 {
pt := resp.Points[0]
if len(pt) < 3 {
t.Fatal("point has too few fields")
}
if pt[0].(float64) != 51.5074 {
t.Errorf("expected lat 51.5074, got: %v", pt[0])
}
}
})
t.Run("fetch with since filter", func(t *testing.T) {
// post another location with later timestamp
postForm(srv, "/api/post.php", url.Values{
"sid": {sid},
"lat": {"52.0"},
"lon": {"0.0"},
"time": {"1234567900.0"},
})
// fetch with since after first point
w := getPath(srv, "/api/fetch.php?id="+shareID+"&since=1234567895")
var resp soloResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("json decode error: %v", err)
}
if len(resp.Points) != 1 {
t.Errorf("expected 1 point after since filter, got: %d", len(resp.Points))
}
})
}
func TestGroupShare(t *testing.T) {
srv, _ := testServer()
// create group share
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"1"},
"nic": {"alice"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if lines[0] != "OK" {
t.Fatalf("group create failed: %s", w.Body.String())
}
aliceSid := lines[1]
pin := lines[3]
shareID := lines[4]
// post alice's location
postForm(srv, "/api/post.php", url.Values{
"sid": {aliceSid},
"lat": {"51.5"},
"lon": {"-0.1"},
"time": {"1234567890"},
})
t.Run("join group", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"2"},
"nic": {"bob"},
"pin": {pin},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if lines[0] != "OK" {
t.Fatalf("join failed: %s", w.Body.String())
}
bobSid := lines[1]
// post bob's location
postForm(srv, "/api/post.php", url.Values{
"sid": {bobSid},
"lat": {"52.0"},
"lon": {"0.0"},
"time": {"1234567891"},
})
})
t.Run("join invalid pin", func(t *testing.T) {
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"2"},
"nic": {"eve"},
"pin": {"999999"},
})
if !strings.Contains(w.Body.String(), "Invalid group PIN!") {
t.Errorf("expected invalid pin error, got: %s", w.Body.String())
}
})
t.Run("fetch group", func(t *testing.T) {
w := getPath(srv, "/api/fetch.php?id="+shareID)
var resp groupResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode error: %v", err)
}
if resp.Type != 1 {
t.Errorf("expected type 1 (group), got: %d", resp.Type)
}
if len(resp.Points) != 2 {
t.Errorf("expected 2 participants, got: %d", len(resp.Points))
}
if _, ok := resp.Points["alice"]; !ok {
t.Error("expected alice in points")
}
if _, ok := resp.Points["bob"]; !ok {
t.Error("expected bob in points")
}
})
}
func TestStopSession(t *testing.T) {
srv, _ := testServer()
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
sid := lines[1]
shareID := lines[3]
t.Run("stop valid", func(t *testing.T) {
w := postForm(srv, "/api/stop.php", url.Values{
"sid": {sid},
})
if !strings.Contains(w.Body.String(), "OK") {
t.Errorf("expected OK, got: %s", w.Body.String())
}
// verify session is gone
w = getPath(srv, "/api/fetch.php?id="+shareID)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404 after stop, got: %d", w.Code)
}
})
t.Run("stop invalid", func(t *testing.T) {
w := postForm(srv, "/api/stop.php", url.Values{
"sid": {"invalid"},
})
// should still return OK (idempotent)
if !strings.Contains(w.Body.String(), "OK") {
t.Errorf("expected OK even for invalid, got: %s", w.Body.String())
}
})
}
func TestDynamic(t *testing.T) {
srv, _ := testServer()
w := getPath(srv, "/dynamic.js.php")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got: %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "TILE_URI") {
t.Error("expected TILE_URI in dynamic.js")
}
if !strings.Contains(body, "VELOCITY_UNIT") {
t.Error("expected VELOCITY_UNIT in dynamic.js")
}
}
func TestVersionHeader(t *testing.T) {
srv, _ := testServer()
w := getPath(srv, "/api/fetch.php?id=test")
version := w.Header().Get("X-Hauk-Version")
if version == "" {
t.Error("expected X-Hauk-Version header")
}
if !strings.Contains(version, "-go") {
t.Errorf("expected version to contain '-go', got: %s", version)
}
}
func TestNewLink(t *testing.T) {
srv, _ := testServer()
// create session
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
sid := lines[1]
t.Run("missing sid", func(t *testing.T) {
w := postForm(srv, "/api/new-link.php", url.Values{})
if !strings.Contains(w.Body.String(), "Missing data!") {
t.Errorf("expected missing data, got: %s", w.Body.String())
}
})
t.Run("invalid session", func(t *testing.T) {
w := postForm(srv, "/api/new-link.php", url.Values{
"sid": {"invalid"},
})
if !strings.Contains(w.Body.String(), "Session expired!") {
t.Errorf("expected session expired, got: %s", w.Body.String())
}
})
t.Run("create new link", func(t *testing.T) {
w := postForm(srv, "/api/new-link.php", url.Values{
"sid": {sid},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if lines[0] != "OK" {
t.Errorf("expected OK, got: %s", w.Body.String())
}
if len(lines) < 3 {
t.Fatalf("expected 3 lines, got: %d", len(lines))
}
if !strings.HasPrefix(lines[1], "https://example.com/") {
t.Errorf("expected view link, got: %s", lines[1])
}
})
t.Run("create adoptable link", func(t *testing.T) {
w := postForm(srv, "/api/new-link.php", url.Values{
"sid": {sid},
"ado": {"1"},
})
if !strings.Contains(w.Body.String(), "OK") {
t.Errorf("expected OK, got: %s", w.Body.String())
}
})
}
func TestAdopt(t *testing.T) {
srv, _ := testServer()
// create group share (owner)
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"1"},
"nic": {"group-owner"},
})
ownerLines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
ownerSid := ownerLines[1]
groupPin := ownerLines[3]
// create adoptable solo share
w = postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"0"},
"ado": {"1"},
})
soloLines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
soloShareID := soloLines[3]
t.Run("missing fields", func(t *testing.T) {
w := postForm(srv, "/api/adopt.php", url.Values{})
if !strings.Contains(w.Body.String(), "Missing data!") {
t.Errorf("expected missing data, got: %s", w.Body.String())
}
})
t.Run("invalid session", func(t *testing.T) {
w := postForm(srv, "/api/adopt.php", url.Values{
"sid": {"invalid"},
"nic": {"adopter"},
"aid": {soloShareID},
"pin": {groupPin},
})
if !strings.Contains(w.Body.String(), "Session expired!") {
t.Errorf("expected session expired, got: %s", w.Body.String())
}
})
t.Run("share not found", func(t *testing.T) {
w := postForm(srv, "/api/adopt.php", url.Values{
"sid": {ownerSid},
"nic": {"adopter"},
"aid": {"nonexistent"},
"pin": {groupPin},
})
if !strings.Contains(w.Body.String(), "Share not found!") {
t.Errorf("expected share not found, got: %s", w.Body.String())
}
})
t.Run("successful adopt", func(t *testing.T) {
w := postForm(srv, "/api/adopt.php", url.Values{
"sid": {ownerSid},
"nic": {"adopted-user"},
"aid": {soloShareID},
"pin": {groupPin},
})
if !strings.Contains(w.Body.String(), "OK") {
t.Errorf("expected OK, got: %s", w.Body.String())
}
})
t.Run("non-adoptable share", func(t *testing.T) {
// create non-adoptable share
w = postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"mod": {"0"},
"ado": {"0"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
nonAdoptableID := lines[3]
w = postForm(srv, "/api/adopt.php", url.Values{
"sid": {ownerSid},
"nic": {"adopter"},
"aid": {nonAdoptableID},
"pin": {groupPin},
})
if !strings.Contains(w.Body.String(), "not allowed") {
t.Errorf("expected not allowed error, got: %s", w.Body.String())
}
})
}
func TestEncryptedSession(t *testing.T) {
srv, _ := testServer()
// create encrypted session
w := postForm(srv, "/api/create.php", url.Values{
"dur": {"3600"},
"int": {"5"},
"pwd": {"test"},
"e2e": {"1"},
"salt": {"abc123"},
})
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
sid := lines[1]
shareID := lines[3]
t.Run("post encrypted location", func(t *testing.T) {
w := postForm(srv, "/api/post.php", url.Values{
"sid": {sid},
"lat": {"encrypted_lat_data"},
"lon": {"encrypted_lon_data"},
"time": {"encrypted_time_data"},
"iv": {"initialization_vector"},
})
if !strings.Contains(w.Body.String(), "OK") {
t.Errorf("expected OK, got: %s", w.Body.String())
}
})
t.Run("fetch encrypted has salt", func(t *testing.T) {
w := getPath(srv, "/api/fetch.php?id="+shareID)
var resp soloResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("json decode error: %v", err)
}
if !resp.Encrypted {
t.Error("expected encrypted flag")
}
if resp.Salt != "abc123" {
t.Errorf("expected salt abc123, got: %s", resp.Salt)
}
})
}