tracker/mockups_test.go
package main
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestMockupArtifactsAndCanonicalFlows(t *testing.T) {
c, err := loadMockCatalog("..")
if err != nil {
t.Fatal(err)
}
if len(c.Screens) != 170 {
t.Fatal(len(c.Screens))
}
uc, err := loadUseCases("..")
if err != nil {
t.Fatal(err)
}
for id, u := range uc.UC {
g, err := draftMockFlow("..", u, c)
if err != nil {
t.Fatal(id, err)
}
if err = validateMockGraph(g, c); err != nil {
t.Fatal(id, err)
}
}
g, err := draftMockFlow("..", uc.UC["W-41"], c)
if err != nil {
t.Fatal(err)
}
found := false
for _, e := range g.Edges {
if e.Label == "ไม่จำเป็น — สมัครด้วยตนเอง" {
found = true
}
}
if !found {
t.Fatal("guardian bypass lost")
}
if _, err = draftMockSitemap("..", c); err != nil {
t.Fatal(err)
}
}
func TestMockupSaveConflictValidationAndRestore(t *testing.T) {
c, _ := loadMockCatalog("..")
g, err := draftMockSitemap("..", c)
if err != nil {
t.Fatal(err)
}
s := &mockStore{dir: t.TempDir()}
saved, err := s.save("sitemap", g, c)
if err != nil || saved.Revision != 1 {
t.Fatal(err)
}
if _, err = s.save("sitemap", g, c); err != errMockConflict {
t.Fatal("stale save accepted", err)
}
saved.Nodes[0].Title = "edited"
saved, err = s.save("sitemap", saved, c)
if err != nil {
t.Fatal(err)
}
restored, err := s.restore("sitemap", 2, 1, c)
if err != nil || restored.Revision != 3 || restored.Nodes[0].Title == "edited" {
t.Fatal("restore failed", err)
}
history, err := s.history("sitemap")
if err != nil || len(history) != 3 {
t.Fatal("history lost", err)
}
bad := restored
bad.Nodes = append([]MockNode(nil), restored.Nodes...)
bad.Nodes[0].ScreenID = "missing"
if validateMockGraph(bad, c) == nil {
t.Fatal("unknown screen accepted")
}
bad = restored
bad.Edges = append([]DiagramEdge(nil), restored.Edges...)
bad.Edges[0].Target = "missing"
if validateMockGraph(bad, c) == nil {
t.Fatal("dangling edge accepted")
}
if _, err = s.load("../../escape", g); err == nil {
t.Fatal("path escape accepted")
}
}
func TestMockupHTTP(t *testing.T) {
store := NewStore(t.TempDir())
mux := newMux(store, NewHub())
for _, path := range []string{"/mockup", "/mockup/sitemap", "/mockup/page-flow", "/mockup/page-flow/W-41", "/mockup/screens", "/mockup/screens/A01", "/api/mockups/catalog", "/api/mockups/graphs/sitemap", "/api/mockups/graphs/W-41"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != 200 {
t.Fatal(path, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/mockups/graphs/sitemap", nil))
var env mockGraphResponse
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(env.Graph)
for _, tc := range []struct {
origin string
want int
}{{"", 403}, {"http://evil.example", 403}, {"http://example.com", 200}} {
req := httptest.NewRequest("PUT", "/api/mockups/graphs/sitemap", strings.NewReader(string(b)))
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != tc.want {
t.Fatal(tc.origin, w.Code, w.Body.String())
}
}
for _, path := range []string{"/mockup/screens/MISSING", "/api/mockups/graphs/MISSING", "/mockup-assets/tracker/data/milestones.json"} {
w = httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != 404 {
t.Fatal(path, w.Code)
}
}
}
func TestMockupConcurrentSavesAndChangedSourcePreserveHistory(t *testing.T) {
c, _ := loadMockCatalog("..")
g, err := draftMockSitemap("..", c)
if err != nil {
t.Fatal(err)
}
s := &mockStore{dir: t.TempDir()}
var wg sync.WaitGroup
results := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() { defer wg.Done(); _, err := s.save("sitemap", g, c); results <- err }()
}
wg.Wait()
close(results)
good, conflicts := 0, 0
for err := range results {
if err == nil {
good++
} else if err == errMockConflict {
conflicts++
} else {
t.Fatal(err)
}
}
if good != 1 || conflicts != 1 {
t.Fatal(good, conflicts)
}
newDraft := g
newDraft.SourceHash = strings.Repeat("a", 64)
loaded, err := s.load("sitemap", newDraft)
if err != nil || loaded.SourceHash == newDraft.SourceHash || loaded.Revision != 1 {
t.Fatal("source change erased saved graph", err)
}
history, err := s.history("sitemap")
if err != nil || len(history) != 1 {
t.Fatal(err)
}
path := filepath.Join(s.dir, "mockups", "graphs", "sitemap.json")
if err = os.WriteFile(path, []byte("broken json"), 0600); err != nil {
t.Fatal(err)
}
if _, err = s.load("sitemap", g); err == nil {
t.Fatal("corrupt data silently reset")
}
}
func TestMockupArtifactPathConfinement(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "private.svg"), []byte("private"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(outside, "private.svg"), filepath.Join(root, "escape.svg")); err != nil {
t.Fatal(err)
}
for _, path := range []string{"../private.svg", "escape.svg", "/etc/passwd", "nested/../secret.svg"} {
if _, err := mockFile(root, path); err == nil {
t.Fatal("allowed path escape", path)
}
}
}
func TestFullModuleLabelsAndFlowGroups(t *testing.T) {
c, err := loadMockCatalog("..")
if err != nil {
t.Fatal(err)
}
uc, err := loadUseCases("..")
if err != nil {
t.Fatal(err)
}
for code, m := range uc.Modules {
if c.ModuleNames[code] != m.Name {
t.Errorf("missing full label for %s", code)
}
}
for _, s := range c.Screens {
if s.ModuleName == "" || s.ModuleName == s.Module {
t.Errorf("abbreviated label for %s", s.ID)
}
}
if moduleLabel(c.ModuleNames, "UNKNOWN") == "UNKNOWN" {
t.Fatal("unknown module leaked abbreviation")
}
mux := newMux(NewStore(t.TempDir()), NewHub())
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/mockup/page-flow", nil))
if w.Code != 200 {
t.Fatal(w.Code)
}
html := w.Body.String()
if strings.Count(html, "data-flow-search=") != 162 || !strings.Contains(html, `data-flow-module="ADM"`) || !strings.Contains(html, "รับสมัครและคัดเลือก") || !strings.Contains(html, "data-flow-collapse-all") {
t.Fatal("module folding or rows missing")
}
}