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
package main

import (
	"context"
	"encoding/json"
	"net/http"
	"net/url"
	"strings"
	"testing"
)

// TestOwnFiles: an own file is added to a course with its origin and uploader,
// is a file like any other (served, cited, linked by a claim), is listed apart
// from the folder tree, gets versions only from the reader, is never marked
// gone by sync, and when withdrawn stays readable and turns claims about it
// stale.
func TestOwnFiles(t *testing.T) {
	s, fake := setup(t)
	ctx := context.Background()
	login(t, s, "good")
	if err := s.sync.runOnce(ctx, "full"); err != nil {
		t.Fatal(err)
	}
	before, _ := course(s.db.Read, cid)
	add := func(q url.Values, body string, h map[string]string) (int, string) {
		return post(t, s, "/course/"+cid+"/own?"+q.Encode(), body, h)
	}
	good := url.Values{"name": {"Greber - Oppositionen.txt"}, "by": {"philip"},
		"origin": {"Scan UB Augsburg 60/EC 1600 B745(3), S. 193-210"}}
	without := func(k string) url.Values {
		q := url.Values{}
		for kk, v := range good {
			if kk != k {
				q[kk] = v
			}
		}
		return q
	}
	for name, c := range map[string]struct {
		q    url.Values
		body string
		h    map[string]string
		want int
	}{
		"no name":    {without("name"), "x", nil, 400},
		"no origin":  {without("origin"), "x", nil, 400},
		"no by":      {without("by"), "x", nil, 400},
		"empty":      {good, "", nil, 400},
		"oversized":  {good, strings.Repeat("x", 1<<20+1), nil, 413},
		"cross-site": {good, "x", map[string]string{"Sec-Fetch-Site": "cross-site"}, 403},
	} {
		if code, body := add(c.q, c.body, c.h); code != c.want {
			t.Errorf("%s: %d, want %d: %s", name, code, c.want, body)
		}
	}
	if code, _ := post(t, s, "/course/nope/own?"+good.Encode(), "x", nil); code != 404 {
		t.Errorf("unknown course: %d", code)
	}
	if n := count(t, s, `SELECT count(*) FROM files WHERE own = 1`); n != 0 {
		t.Fatalf("refused uploads stored %d own files", n)
	}

	text := "Erika Greber\nOppositionen\n\nDie binäre Opposition ist das Grundmuster.\n"
	code, body := add(good, text, nil)
	if code != http.StatusCreated || !strings.HasPrefix(body, "/file/own-1\n") {
		t.Fatalf("add: %d %s", code, body)
	}
	const own = "own-1"

	// A file like any other: served, typed by its bytes, with its version.
	if code, b := get(t, s, "/file/"+own+"/content", false); code != 200 || b != text {
		t.Fatalf("content: %d %q", code, b)
	}
	f, err := file(s.db.Read, own)
	if err != nil {
		t.Fatal(err)
	}
	if !f.Own || f.Origin != good.Get("origin") || f.AddedBy != "philip" || f.FolderID != "" ||
		f.Versions != 1 || !strings.HasPrefix(f.Mime, "text/plain") {
		t.Fatalf("stored: %+v", f)
	}

	// Its page says what it is, and does not pretend to be on Digicampus.
	_, page := get(t, s, "/file/"+own, true)
	for _, want := range []string{"OWN FILE", "added here by philip", "S. 193-210", "POST /file/own-1/withdraw"} {
		if !strings.Contains(page, want) {
			t.Errorf("file page lacks %q:\n%s", want, page)
		}
	}
	if strings.Contains(page, "digicampus:") {
		t.Errorf("an own file's page links to Digicampus:\n%s", page)
	}
	_, html := get(t, s, "/file/"+own, false)
	if !strings.Contains(html, "An own file") || strings.Contains(html, "on Digicampus</a>") {
		t.Errorf("file page HTML:\n%s", html)
	}

	// Listed apart from the folder tree, in both renderings, and not
	// counted as one of the course's Digicampus files.
	_, cp := get(t, s, "/course/"+cid, true)
	fi, oi := strings.Index(cp, " files ---"), strings.Index(cp, " own files ---")
	if fi < 0 || oi < fi {
		t.Fatalf("course page sections:\n%s", cp)
	}
	if files := cp[fi:oi]; strings.Contains(files, own) {
		t.Errorf("own file in the folder tree:\n%s", files)
	}
	if !strings.Contains(cp[oi:], "/file/"+own) {
		t.Errorf("course page lacks the own files:\n%s", cp)
	}
	if co, _ := course(s.db.Read, cid); co.Files != before.Files || co.FileBytes != before.FileBytes {
		t.Errorf("course counts %d files, %d bytes, was %d, %d; the own file is not Digicampus's",
			co.Files, co.FileBytes, before.Files, before.FileBytes)
	}
	if _, h := get(t, s, "/course/"+cid, false); !strings.Contains(h, `id="own"`) {
		t.Error("course page HTML lacks the own files section")
	}
	// Adding one is not a change upstream: not in the feed.
	if n := count(t, s, `SELECT count(*) FROM changes WHERE item_id = ?`, own); n != 0 {
		t.Errorf("own file recorded %d changes", n)
	}

	// Sync never marks it gone, however often it runs.
	for i := 0; i < 2; i++ {
		if err := s.sync.runOnce(ctx, "full"); err != nil {
			t.Fatal(err)
		}
	}
	if f, _ := file(s.db.Read, own); !f.Gone.IsZero() {
		t.Fatal("sync marked an own file gone")
	}
	// ... while a Stud.IP file it no longer lists still is.
	fake.mu.Lock()
	fake.withFile = false
	fake.mu.Unlock()
	s.sync.runOnce(ctx, "full")
	if f, _ := file(s.db.Read, fileID); f.Gone.IsZero() {
		t.Fatal("the sweep no longer marks Stud.IP files gone")
	}
	if f, _ := file(s.db.Read, own); !f.Gone.IsZero() {
		t.Fatal("sync marked an own file gone")
	}

	// A claim cites it and is about it.
	claim := func(req map[string]any) (int, string) {
		b, _ := json.Marshal(req)
		return post(t, s, "/course/"+cid+"/claims", string(b), map[string]string{"Content-Type": "application/json"})
	}
	if code, b := claim(map[string]any{"by": "claude", "source": map[string]any{"kind": "file", "id": own},
		"claims": []any{map[string]any{"field": "literature", "value": "Greber über Oppositionen", "file": own,
			"lines": "4", "quote": "binäre Opposition"}}}); code != 200 && code != 201 {
		t.Fatalf("claim from an own file: %d %s", code, b)
	}
	// Its name is the uploader's, not the course's.
	if code, _ := claim(map[string]any{"by": "claude", "source": map[string]any{"kind": "file-entry", "id": own},
		"claims": []any{map[string]any{"field": "literature", "value": "x", "quote": "Greber"}}}); code != 400 {
		t.Errorf("a claim read from an own file's name: %d, want 400", code)
	}

	// Versions come only from the reader, and only for own files.
	v := url.Values{"by": {"philip"}, "origin": {"Scan UB, besser"}}
	if code, b := post(t, s, "/file/"+fileID+"/version?"+v.Encode(), "x", nil); code != 400 {
		t.Errorf("a version of a Stud.IP file: %d %s", code, b)
	}
	if code, _ := post(t, s, "/file/"+own+"/version?by=philip", "x", nil); code != 400 {
		t.Errorf("a version without origin: %d", code)
	}
	if code, b := post(t, s, "/file/"+own+"/version?"+v.Encode(), text, nil); code != 200 || !strings.Contains(b, "nothing stored") {
		t.Errorf("the same bytes again: %d %s", code, b)
	}
	text2 := text + "Zweite Seite.\n"
	if code, b := post(t, s, "/file/"+own+"/version?"+v.Encode(), text2, nil); code != http.StatusCreated {
		t.Fatalf("version 2: %d %s", code, b)
	}
	if f, _ := file(s.db.Read, own); f.Versions != 2 || f.Origin != "Scan UB, besser" {
		t.Fatalf("after version 2: %+v", f)
	}
	if _, b := get(t, s, "/file/"+own+"/version/1", false); b != text {
		t.Errorf("version 1 changed: %q", b)
	}

	// The claim was read from version 1; version 2 makes it stale, as for
	// any file. Withdrawn below, it names the withdrawal once that is all
	// that is wrong: so the claim is re-read from version 2 first.
	claim(map[string]any{"by": "claude", "source": map[string]any{"kind": "file", "id": own}, "supersede": true,
		"claims": []any{map[string]any{"field": "literature", "value": "Greber über Oppositionen", "file": own,
			"lines": "4", "quote": "binäre Opposition"}}})

	// Withdrawn: kept, marked, and the claim about it says so.
	if code, _ := post(t, s, "/file/"+fileID+"/withdraw", "", nil); code != 400 {
		t.Errorf("withdrawing a Stud.IP file: %d", code)
	}
	if code, b := post(t, s, "/file/"+own+"/withdraw", "", nil); code != 200 {
		t.Fatalf("withdraw: %d %s", code, b)
	}
	if code, b := get(t, s, "/file/"+own+"/content", false); code != 200 || b != text2 {
		t.Errorf("withdrawn content: %d %q", code, b)
	}
	if code, _ := post(t, s, "/file/"+own+"/version?"+v.Encode(), "y", nil); code != 400 {
		t.Errorf("a version of a withdrawn own file: %d", code)
	}
	_, cp = get(t, s, "/course/"+cid, true)
	if !strings.Contains(cp, "WITHDRAWN") {
		t.Errorf("course page does not mark it withdrawn:\n%s", cp)
	}
	cs, err := loadClaims(s.db.Read, `file_id = ? AND withdrawn_at IS NULL`, own)
	if err == nil {
		cs, err = annotateClaims(s.db.Read, cs)
	}
	if err != nil || len(cs) != 1 || !strings.Contains(cs[0].Stale, "withdrawn") {
		t.Fatalf("claim about the withdrawn own file: %v %+v", err, cs)
	}
}

// TestOwnFileDocx: an own Word document is converted when it is added, as a
// Stud.IP one is when it is fetched, and can be cited by line.
func TestOwnFileDocx(t *testing.T) {
	s, _ := setup(t)
	login(t, s, "good")
	if err := s.sync.runOnce(context.Background(), "full"); err != nil {
		t.Fatal(err)
	}
	doc := makeDocx(t, "Meine Zusammenfassung")
	q := url.Values{"name": {"Zusammenfassung.docx"}, "by": {"philip"}, "origin": {"selbst geschrieben"}}
	if code, b := post(t, s, "/course/"+cid+"/own?"+q.Encode(), string(doc), nil); code != http.StatusCreated {
		t.Fatalf("add: %d %s", code, b)
	}
	if _, page := get(t, s, "/file/own-1", true); !strings.Contains(page, "Meine Zusammenfassung") {
		t.Errorf("the docx was not converted:\n%s", page)
	}
}