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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
|
package admin
// Regression tests for the undo-history account-file paths re-verified in the
// infra pass:
// - a history image whose password hash was redacted on disk cannot be
// written over an account after a restart (login could never repair the
// placeholder, and the raw editor copies it back)
// - undo/redo refuses a character/account file whose owner is online, the
// same refusal the raw file editor applies
// - the refusal is scoped to player files: other data files undo normally
import (
"os"
"path/filepath"
"strings"
"testing"
)
const (
testAccountHash = "argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef"
testAccountLive = "name: bob\npassword_hash: " + testAccountHash + "\nadmin: false\n"
testAccountPromote = "name: bob\npassword_hash: " + testAccountHash + "\nadmin: true\n"
)
func writeAccountFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
// TestUndoOfRedactedAccountImageDoesNotClobberAccount drives the restart the
// history file exists to survive: Push redacts the persisted image, a fresh
// UndoStack loads that redacted image, and the undo must then fail rather than
// write "[redacted]" into a live account.
func TestUndoOfRedactedAccountImageDoesNotClobberAccount(t *testing.T) {
t.Run("delete of a removed account fails without creating it", func(t *testing.T) {
dir := t.TempDir()
accPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
writeAccountFile(t, accPath, testAccountLive)
us := NewUndoStack(dir)
us.Push(ChangeDesc{
Description: "delete bob",
FilePath: accPath,
OldContent: []byte(testAccountLive),
IsDelete: true,
})
if err := os.Remove(accPath); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, ".admin_history.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), redactedAccountHash) {
t.Fatalf("history file did not redact the hash:\n%s", raw)
}
restarted := NewUndoStack(dir)
if ch := restarted.Undo(); ch != nil {
t.Fatalf("undo wrote a redacted account image: %s", ch.ShortDesc())
}
if got, err := os.ReadFile(accPath); err == nil {
t.Fatalf("undo recreated the account from the redacted image:\n%s", got)
} else if !os.IsNotExist(err) {
t.Fatalf("stat recreated account: %v", err)
}
})
t.Run("rename restores the real hash from the renamed file", func(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
newPath := filepath.Join(dir, "players", "accounts", "robert.yaml")
writeAccountFile(t, oldPath, testAccountLive)
us := NewUndoStack(dir)
us.Push(ChangeDesc{
Description: "rename bob",
FilePath: oldPath,
NewFilePath: newPath,
OldContent: []byte(testAccountLive),
NewContent: []byte(testAccountLive),
})
if err := os.Rename(oldPath, newPath); err != nil {
t.Fatal(err)
}
restarted := NewUndoStack(dir)
if ch := restarted.Undo(); ch == nil {
t.Fatal("undo of a rename must restore the account from its live renamed copy")
}
got, err := os.ReadFile(oldPath)
if err != nil {
t.Fatalf("undo did not restore the old path: %v", err)
}
if !strings.Contains(string(got), testAccountHash) {
t.Fatalf("restored account lost its hash:\n%s", got)
}
if strings.Contains(string(got), redactedAccountHash) {
t.Fatalf("restored account carries the redaction placeholder:\n%s", got)
}
if _, err := os.Stat(newPath); !os.IsNotExist(err) {
t.Fatalf("undo left the renamed file behind: %v", err)
}
})
t.Run("rename whose only copy is gone fails before removing anything", func(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
newPath := filepath.Join(dir, "players", "accounts", "robert.yaml")
us := NewUndoStack(dir)
us.Push(ChangeDesc{
Description: "rename bob",
FilePath: oldPath,
NewFilePath: newPath,
OldContent: []byte(testAccountLive),
NewContent: []byte(testAccountLive),
})
restarted := NewUndoStack(dir)
if ch := restarted.Undo(); ch != nil {
t.Fatalf("undo wrote a redacted account image: %s", ch.ShortDesc())
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("failed undo created the account from the redacted image: %v", err)
}
})
t.Run("edit keeps the live hash", func(t *testing.T) {
dir := t.TempDir()
accPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
writeAccountFile(t, accPath, testAccountPromote)
us := NewUndoStack(dir)
us.Push(ChangeDesc{
Description: "make bob an admin",
FilePath: accPath,
OldContent: []byte(testAccountLive),
NewContent: []byte(testAccountPromote),
})
// The live file still holds the real hash after the restart below, so
// the undo has one to merge and must keep working.
restarted := NewUndoStack(dir)
if ch := restarted.Undo(); ch == nil {
t.Fatal("undo of a redacted edit must still apply while the live account file exists")
}
got, err := os.ReadFile(accPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), testAccountHash) {
t.Fatalf("undo did not merge the live hash into the restored image:\n%s", got)
}
if !strings.Contains(string(got), "admin: false") {
t.Fatalf("undo did not restore the pre-edit image:\n%s", got)
}
})
}
// TestUndoRedoRefusesOnlinePlayerFile wires the online check the way main.go
// does and pins that undo and redo both consult it for a live account, and
// that the check receives the data-relative path form the game expects.
func TestUndoRedoRefusesOnlinePlayerFile(t *testing.T) {
dir := t.TempDir()
accPath := filepath.Join(dir, "players", "accounts", "alice.yaml")
writeAccountFile(t, accPath, testAccountLive)
s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
var seen []string
online := true
s.SetPlayerFileOnlineCheck(func(relPath string) bool {
seen = append(seen, relPath)
return online && relPath == "players/accounts/alice.yaml"
})
s.undoStack.Push(ChangeDesc{
Description: "make alice an admin",
FilePath: accPath,
OldContent: []byte(testAccountLive),
NewContent: []byte(testAccountPromote),
})
writeAccountFile(t, accPath, testAccountPromote)
if ch := s.undoStack.Undo(); ch != nil {
t.Fatalf("undo overwrote an online account file: %s", ch.ShortDesc())
}
if got, _ := os.ReadFile(accPath); string(got) != testAccountPromote {
t.Fatalf("online account file was modified:\n%s", got)
}
if len(seen) == 0 || seen[0] != "players/accounts/alice.yaml" {
t.Fatalf("online check paths = %v, want the data-relative account path first", seen)
}
// Logging the character out clears the refusal, and the redo of the same
// change is refused when it is online again. A successful undo rewrites
// the mapping (so its key order differs), hence the byte snapshot rather
// than a comparison against the original text.
online = false
if ch := s.undoStack.Undo(); ch == nil {
t.Fatal("undo still refused after the character logged out")
}
undone, err := os.ReadFile(accPath)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"admin: false", testAccountHash} {
if !strings.Contains(string(undone), want) {
t.Fatalf("undo did not apply %q after logout:\n%s", want, undone)
}
}
online = true
if ch := s.undoStack.Redo(); ch != nil {
t.Fatalf("redo overwrote an online account file: %s", ch.ShortDesc())
}
if got, _ := os.ReadFile(accPath); string(got) != string(undone) {
t.Fatalf("redo modified an online account file:\n%s", got)
}
online = false
if ch := s.undoStack.Redo(); ch == nil {
t.Fatal("redo still refused after the character logged out")
}
}
// TestUndoOfNonPlayerFileIgnoresOnlineCheck: the refusal is scoped to player
// files, so a hook that reports every path online cannot block room or item
// undo.
func TestUndoOfNonPlayerFileIgnoresOnlineCheck(t *testing.T) {
dir := t.TempDir()
roomPath := filepath.Join(dir, "rooms", "1.yaml")
if err := os.MkdirAll(filepath.Dir(roomPath), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(roomPath, []byte("id: 1\nname: edited\n"), 0644); err != nil {
t.Fatal(err)
}
us := NewUndoStack(dir)
us.SetPlayerFileOnlineCheck(func(string) bool { return true })
us.Push(ChangeDesc{
Description: "edit room",
FilePath: roomPath,
OldContent: []byte("id: 1\nname: old\n"),
NewContent: []byte("id: 1\nname: edited\n"),
})
if ch := us.Undo(); ch == nil {
t.Fatal("undo of a non-player file was refused by the online check")
}
got, err := os.ReadFile(roomPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), "name: old") {
t.Fatalf("undo did not revert the room file:\n%s", got)
}
}
|