aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_entity_test.go
blob: b3d83a7626626e2ccef58f1e3d51afd80b6e9276 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
package admin

// Regression coverage for the shared entity CRUD handlers (api_entity.go):
// both wire styles must keep their exact contract after the per-entity
// handlers were folded into one implementation.

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func newEntityHarness(t *testing.T) *AdminServer {
	t.Helper()
	dir := t.TempDir()
	for _, sub := range []string{"items", "techs", "modules"} {
		if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
			t.Fatal(err)
		}
	}
	return &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
}

func callHandler(h http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, path, strings.NewReader(body))
	rec := httptest.NewRecorder()
	h(rec, req)
	return rec
}

// Status style (items): validation errors are HTTP statuses, writes echo the
// entity map including _raw.
func TestEntityCRUDStatusStyle(t *testing.T) {
	s := newEntityHarness(t)
	list := s.handleEntityList(itemSpec)
	byID := s.handleEntityByID(itemSpec)

	if rec := callHandler(list, http.MethodPost, "/api/items", `{"id":"coin","name":"Coin","value":1}`); rec.Code != http.StatusOK {
		t.Fatalf("create status = %d body %s", rec.Code, rec.Body.String())
	}
	// The id is written into the file for the status style.
	data, err := os.ReadFile(filepath.Join(s.dataDir, "items", "coin.yaml"))
	if err != nil || !strings.Contains(string(data), "id: coin") {
		t.Fatalf("created file = %q err=%v", data, err)
	}

	rec := callHandler(byID, http.MethodGet, "/api/items/coin", "")
	var got map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatalf("get not json: %v (%s)", err, rec.Body.String())
	}
	if _, ok := got["_raw"]; !ok {
		t.Fatalf("status-style get did not include _raw: %s", rec.Body.String())
	}

	rec = callHandler(byID, http.MethodPut, "/api/items/coin", `{"name":"Coin","value":9}`)
	if rec.Code != http.StatusOK {
		t.Fatalf("update status = %d body %s", rec.Code, rec.Body.String())
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["_raw"] == nil {
		t.Fatalf("status-style update did not echo _raw: %v (%s)", err, rec.Body.String())
	}

	// Traversal ids are rejected with a 400 (not a 200 body error).
	rec = callHandler(list, http.MethodPost, "/api/items", `{"id":"../escape"}`)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("traversal id status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
	}

	if rec := callHandler(byID, http.MethodDelete, "/api/items/coin", ""); rec.Code != http.StatusOK {
		t.Fatalf("delete status = %d body %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(s.dataDir, "items", "coin.yaml")); !os.IsNotExist(err) {
		t.Fatalf("delete left the file behind: %v", err)
	}
}

// Body style (techs): errors are a 200 JSON body, writes reply {"ok":true},
// and PUT upserts an absent file.
func TestEntityCRUDUpsertStyle(t *testing.T) {
	s := newEntityHarness(t)
	list := s.handleEntityList(techSpec)
	byID := s.handleEntityByID(techSpec)

	rec := callHandler(byID, http.MethodPut, "/api/techs/laser", `{"name":"Laser"}`)
	if rec.Code != http.StatusOK {
		t.Fatalf("upsert status = %d body %s", rec.Code, rec.Body.String())
	}
	var resp map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp["ok"] != true {
		t.Fatalf("upsert response = %s (err %v)", rec.Body.String(), err)
	}
	if _, err := os.Stat(filepath.Join(s.dataDir, "techs", "laser.yaml")); err != nil {
		t.Fatalf("upsert did not create the file: %v", err)
	}

	// The body style strips the id from the file (derived from the filename).
	data, err := os.ReadFile(filepath.Join(s.dataDir, "techs", "laser.yaml"))
	if err != nil || strings.Contains(string(data), "id:") {
		t.Fatalf("body-style file should not store id: %q err=%v", data, err)
	}

	// Traversal ids are a 200 body error, not a 400 (the newer editors read
	// res.error out of a 200).
	rec = callHandler(list, http.MethodPost, "/api/techs", `{"id":"../escape"}`)
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "invalid id") {
		t.Fatalf("body-style traversal id = %d %s; want 200 with 'invalid id'", rec.Code, rec.Body.String())
	}

	// GET echoes the id from the filename.
	rec = callHandler(byID, http.MethodGet, "/api/techs/laser", "")
	var got map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["id"] != "laser" {
		t.Fatalf("body-style get id = %v (err %v, body %s)", got["id"], err, rec.Body.String())
	}
}

// A by-id write must update a file that lives in a subdirectory, not create a
// shadowing copy at the root. The old module handler resolved updates only at
// the root while GET/DELETE searched subdirs.
func TestEntityUpsertUpdatesFileInSubdir(t *testing.T) {
	s := newEntityHarness(t)
	sub := filepath.Join(s.dataDir, "modules", "sub")
	if err := os.MkdirAll(sub, 0o755); err != nil {
		t.Fatal(err)
	}
	subPath := filepath.Join(sub, "m1.yaml")
	if err := os.WriteFile(subPath, []byte("name: Original\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	rec := callHandler(s.handleEntityByID(moduleSpec), http.MethodPut, "/api/modules/m1", `{"name":"Updated"}`)
	if rec.Code != http.StatusOK {
		t.Fatalf("subdir update status = %d body %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(s.dataDir, "modules", "m1.yaml")); !os.IsNotExist(err) {
		t.Fatal("subdir update created a shadowing root copy")
	}
	data, err := os.ReadFile(subPath)
	if err != nil || !strings.Contains(string(data), "Updated") {
		t.Fatalf("subdir file not updated: %q err=%v", data, err)
	}
}