@synopackageland/templates 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/package.json +11 -4
- package/templates/_shared/AGENTS.md +146 -0
- package/templates/_shared/CLAUDE.md +1 -0
- package/templates/_shared/GO-LICENSE +27 -0
- package/templates/_shared/GO-PATENTS +22 -0
- package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
- package/templates/_shared/third-party-notices.mjs +55 -0
- package/templates/binary/LICENSES/GO-LICENSE +27 -0
- package/templates/binary/LICENSES/GO-PATENTS +22 -0
- package/templates/binary/dsm-package.yaml +46 -0
- package/templates/binary/example/main.go +16 -0
- package/templates/binary/gitignore +2 -0
- package/templates/binary/i18n/messages/de.json +4 -0
- package/templates/binary/i18n/messages/en.json +4 -0
- package/templates/binary/i18n/messages/es.json +4 -0
- package/templates/binary/i18n/messages/fr.json +4 -0
- package/templates/binary/i18n/messages/it.json +4 -0
- package/templates/binary/i18n/messages/ja.json +4 -0
- package/templates/binary/i18n/messages/ko.json +4 -0
- package/templates/binary/i18n/messages/zh-CN.json +4 -0
- package/templates/binary/i18n/messages/zh-TW.json +4 -0
- package/templates/binary/ui/app.css +4 -0
- package/templates/binary/ui/app.js +32 -0
- package/templates/compose/compose.yaml +8 -0
- package/templates/compose/synology-app.yaml +16 -0
- package/templates/native-go/README.md +368 -0
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
- package/templates/native-go/backend/cmd/app/main.go +101 -0
- package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
- package/templates/native-go/backend/go.mod +9 -0
- package/templates/native-go/backend/go.sum +4 -0
- package/templates/native-go/backend/internal/api/permissions.go +26 -0
- package/templates/native-go/backend/internal/api/permissions.json +5 -0
- package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
- package/templates/native-go/backend/internal/app/background.go +249 -0
- package/templates/native-go/backend/internal/app/background_none.go +8 -0
- package/templates/native-go/backend/internal/app/background_owner.go +38 -0
- package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
- package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
- package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
- package/templates/native-go/backend/internal/app/background_test.go +320 -0
- package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
- package/templates/native-go/backend/internal/app/delegate_config_test.go +28 -0
- package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
- package/templates/native-go/backend/internal/app/identity.go +20 -0
- package/templates/native-go/backend/internal/app/identity_test.go +15 -0
- package/templates/native-go/backend/internal/app/migrations.go +41 -0
- package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
- package/templates/native-go/backend/internal/app/portal.go +30 -0
- package/templates/native-go/backend/internal/app/portal_test.go +55 -0
- package/templates/native-go/backend/internal/app/root_none.go +9 -0
- package/templates/native-go/backend/internal/app/routes.go +137 -0
- package/templates/native-go/backend/internal/app/routes_test.go +197 -0
- package/templates/native-go/backend/internal/app/session_files.go +9 -0
- package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
- package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
- package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
- package/templates/native-go/dsm-package.yaml +71 -0
- package/templates/native-go/gitignore +1 -0
- package/templates/native-go/i18n/glossary.json +663 -0
- package/templates/native-go/i18n/messages/de.json +29 -0
- package/templates/native-go/i18n/messages/en.json +29 -0
- package/templates/native-go/i18n/messages/es.json +29 -0
- package/templates/native-go/i18n/messages/fr.json +29 -0
- package/templates/native-go/i18n/messages/it.json +29 -0
- package/templates/native-go/i18n/messages/ja.json +29 -0
- package/templates/native-go/i18n/messages/ko.json +29 -0
- package/templates/native-go/i18n/messages/zh-CN.json +29 -0
- package/templates/native-go/i18n/messages/zh-TW.json +29 -0
- package/templates/native-go/ui/app.css +45 -0
- package/templates/native-go/ui/app.js +111 -0
- package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
- package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
- package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
- package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
- package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
- package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
- package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
- package/templates/native-go-root-ops/backend/internal/app/executor.go +140 -0
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +118 -0
- package/templates/native-go-root-ops/root/whoami.sh +3 -0
- package/templates/native-go-vue/README.md +51 -0
- package/templates/native-go-vue/frontend/DESIGN.md +61 -0
- package/templates/native-go-vue/frontend/package-lock.json +2550 -0
- package/templates/native-go-vue/frontend/package.json +23 -0
- package/templates/native-go-vue/frontend/src/App.vue +90 -0
- package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
- package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
- package/templates/native-go-vue/frontend/src/main.ts +33 -0
- package/templates/native-go-vue/frontend/src/style.css +27 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
- package/templates/native-go-vue/frontend/tsconfig.json +1 -0
- package/templates/native-go-vue/frontend/vite.config.ts +11 -0
- package/templates/native-go-vue/gitignore +2 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
15
|
+
"github.com/synopackageland/syno-pkg-land-sdk/background"
|
|
16
|
+
"github.com/synopackageland/syno-pkg-land-sdk/filestation"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type fakeFiles struct {
|
|
20
|
+
data []byte
|
|
21
|
+
err error
|
|
22
|
+
calls int
|
|
23
|
+
folder string
|
|
24
|
+
rel string
|
|
25
|
+
offset int
|
|
26
|
+
page filestation.Page
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func (f *fakeFiles) Browse(_ context.Context, _ api.Caller, _ *http.Request, folder string, offset int) (filestation.Page, error) {
|
|
30
|
+
f.calls++
|
|
31
|
+
f.folder, f.offset = folder, offset
|
|
32
|
+
return f.page, f.err
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func (f *fakeFiles) ReadFile(_ context.Context, _ api.Caller, _ *http.Request, folder, rel string) ([]byte, error) {
|
|
36
|
+
f.calls++
|
|
37
|
+
f.folder, f.rel = folder, rel
|
|
38
|
+
return f.data, f.err
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type bgEnv struct {
|
|
42
|
+
t *testing.T
|
|
43
|
+
store *background.Store
|
|
44
|
+
files *fakeFiles
|
|
45
|
+
srv map[string]*api.Server
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func newBgEnv(t *testing.T) *bgEnv {
|
|
49
|
+
lookupSubmitterUID = func(string) string { return "1024" }
|
|
50
|
+
t.Cleanup(func() { lookupSubmitterUID = osLookupUID })
|
|
51
|
+
store := background.NewStore(filepath.Join(t.TempDir(), "background"))
|
|
52
|
+
files := &fakeFiles{data: []byte("hello")}
|
|
53
|
+
return &bgEnv{t: t, store: store, files: files, srv: map[string]*api.Server{}}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func (e *bgEnv) call(auth api.Authenticator, method, params string) (string, map[string]any) {
|
|
57
|
+
e.t.Helper()
|
|
58
|
+
// SDK policy provides owner routes immediately.
|
|
59
|
+
routes := newBackgroundRoutes(e.store, e.files)
|
|
60
|
+
srv, err := api.NewServerWithPermissions(auth, []byte(`{"schema":1,"default":"administrators","methods":{}}`), routes...)
|
|
61
|
+
if err != nil {
|
|
62
|
+
e.t.Fatal(err)
|
|
63
|
+
}
|
|
64
|
+
req := httptest.NewRequest(http.MethodPost, "/api.cgi", strings.NewReader(`{"method":"`+method+`","params":`+params+`}`))
|
|
65
|
+
req.Header.Set("Content-Type", "application/json")
|
|
66
|
+
rec := httptest.NewRecorder()
|
|
67
|
+
srv.ServeHTTP(rec, req)
|
|
68
|
+
var out map[string]any
|
|
69
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
70
|
+
e.t.Fatalf("not JSON: %s", rec.Body.String())
|
|
71
|
+
}
|
|
72
|
+
code := ""
|
|
73
|
+
if er, ok := out["error"].(map[string]any); ok {
|
|
74
|
+
code, _ = er["code"].(string)
|
|
75
|
+
}
|
|
76
|
+
return code, out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func data(out map[string]any) map[string]any { return out["data"].(map[string]any) }
|
|
80
|
+
|
|
81
|
+
var bgSample = map[string]string{
|
|
82
|
+
"bg.schedules.list": `{}`,
|
|
83
|
+
"bg.schedules.create": `{"key":"schedule","jobId":"missing","intervalSeconds":3600,"consent":true}`,
|
|
84
|
+
"bg.schedules.disable": `{"id":"missing"}`,
|
|
85
|
+
"bg.schedules.delete": `{"id":"missing"}`,
|
|
86
|
+
"bg.schedules.enable": `{"id":"missing","consent":true}`,
|
|
87
|
+
"bg.status": `{}`,
|
|
88
|
+
"bg.jobs.purge": `{"owner":{"user":"bob","uid":"1025"}}`,
|
|
89
|
+
"bg.browse": `{"folder":"/share","offset":0}`,
|
|
90
|
+
"bg.jobs.submit": `{"key":"k1","folder":"/share","relative":"a.txt"}`,
|
|
91
|
+
"bg.jobs.list": `{}`,
|
|
92
|
+
"bg.jobs.get": `{"id":"` + strings.Repeat("a", 32) + `"}`,
|
|
93
|
+
"bg.jobs.cancel": `{"id":"` + strings.Repeat("a", 32) + `"}`,
|
|
94
|
+
"bg.jobs.result": `{"id":"` + strings.Repeat("a", 32) + `"}`,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestBackgroundSDKPoliciesAuthenticateEveryRoute(t *testing.T) {
|
|
98
|
+
e := newBgEnv(t)
|
|
99
|
+
routes := newBackgroundRoutes(e.store, e.files)
|
|
100
|
+
if len(routes) != len(bgSample) {
|
|
101
|
+
t.Fatalf("%d routes, %d samples", len(routes), len(bgSample))
|
|
102
|
+
}
|
|
103
|
+
for _, r := range routes {
|
|
104
|
+
if rule(t, r) == "public" {
|
|
105
|
+
t.Errorf("%s must not be public", r.Method())
|
|
106
|
+
}
|
|
107
|
+
params, ok := bgSample[r.Method()]
|
|
108
|
+
if !ok {
|
|
109
|
+
t.Fatalf("no sample for %s", r.Method())
|
|
110
|
+
}
|
|
111
|
+
code, _ := e.call(bob, r.Method(), params)
|
|
112
|
+
if r.Method() == "bg.jobs.purge" || r.Method() == "bg.schedules.enable" {
|
|
113
|
+
if code != "admin_required" {
|
|
114
|
+
t.Errorf("%s as ordinary user: %q", r.Method(), code)
|
|
115
|
+
}
|
|
116
|
+
} else if code == "admin_required" || code == "unsupported" || code == "disabled" {
|
|
117
|
+
t.Errorf("%s unexpectedly blocked: %q", r.Method(), code)
|
|
118
|
+
}
|
|
119
|
+
if code, _ := e.call(nobody, r.Method(), params); code != "session_required" {
|
|
120
|
+
t.Errorf("%s without a session: %q", r.Method(), code)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func TestBackgroundStatusReportsLimitsAndWorker(t *testing.T) {
|
|
126
|
+
e := newBgEnv(t)
|
|
127
|
+
code, out := e.call(alice, "bg.status", `{}`)
|
|
128
|
+
d := data(out)
|
|
129
|
+
if code != "" || d["algorithm"] != "sha256-v1" || d["worker"] != nil {
|
|
130
|
+
t.Fatalf("%s %v", code, out)
|
|
131
|
+
}
|
|
132
|
+
limits := d["limits"].(map[string]any)
|
|
133
|
+
if limits["maxInputBytes"].(float64) != 512*1024 || limits["maxQueued"].(float64) != 16 || limits["maxTotalBytes"].(float64) != 16<<20 || limits["jobTimeoutSeconds"].(float64) != 60 {
|
|
134
|
+
t.Fatalf("limits: %v", limits)
|
|
135
|
+
}
|
|
136
|
+
if err := e.store.Heartbeat(context.Background(), background.WorkerInfo{PID: 9, UID: 255853, Username: "pkg"}); err != nil {
|
|
137
|
+
t.Fatal(err)
|
|
138
|
+
}
|
|
139
|
+
_, out = e.call(alice, "bg.status", `{}`)
|
|
140
|
+
w := data(out)["worker"].(map[string]any)
|
|
141
|
+
if w["alive"] != true || w["uid"].(float64) != 255853 {
|
|
142
|
+
t.Fatalf("worker: %v", w)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func TestBackgroundSubmitAvailableOnFreshStore(t *testing.T) {
|
|
147
|
+
e := newBgEnv(t)
|
|
148
|
+
code, _ := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"])
|
|
149
|
+
if code != "" || e.files.calls != 1 {
|
|
150
|
+
t.Fatalf("%q, NAS reads %d", code, e.files.calls)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func TestBackgroundSubmitImportsSelectedFileAndIsIdempotent(t *testing.T) {
|
|
155
|
+
e := newBgEnv(t)
|
|
156
|
+
params := `{"key":"k1","folder":"/share","relative":"docs/a.txt"}`
|
|
157
|
+
code, out := e.call(alice, "bg.jobs.submit", params)
|
|
158
|
+
job := data(out)["job"].(map[string]any)
|
|
159
|
+
if code != "" || job["state"] != "queued" || job["name"] != "a.txt" || data(out)["duplicate"] != false {
|
|
160
|
+
t.Fatalf("%s %v", code, out)
|
|
161
|
+
}
|
|
162
|
+
if e.files.folder != "/share" || e.files.rel != "docs/a.txt" {
|
|
163
|
+
t.Fatalf("adapter saw %q %q", e.files.folder, e.files.rel)
|
|
164
|
+
}
|
|
165
|
+
submitter := job["submitter"].(map[string]any)
|
|
166
|
+
if submitter["user"] != "alice" || submitter["uid"] != "1024" {
|
|
167
|
+
t.Fatalf("submitter: %v", submitter)
|
|
168
|
+
}
|
|
169
|
+
_, again := e.call(alice, "bg.jobs.submit", params)
|
|
170
|
+
if data(again)["duplicate"] != true || data(again)["job"].(map[string]any)["id"] != job["id"] {
|
|
171
|
+
t.Fatalf("duplicate: %v", again)
|
|
172
|
+
}
|
|
173
|
+
e.files.data = []byte("other")
|
|
174
|
+
if code, _ := e.call(alice, "bg.jobs.submit", params); code != "conflict" {
|
|
175
|
+
t.Fatalf("same key with different bytes: %q", code)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func TestBackgroundSubmitPassesAdapterRefusalsThrough(t *testing.T) {
|
|
180
|
+
e := newBgEnv(t)
|
|
181
|
+
e.files.err = &api.Error{Code: "limit-exceeded", Message: "x"}
|
|
182
|
+
if code, _ := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"]); code != "limit-exceeded" {
|
|
183
|
+
t.Fatalf("%q", code)
|
|
184
|
+
}
|
|
185
|
+
if jobs, _ := e.store.List(context.Background()); len(jobs) != 0 {
|
|
186
|
+
t.Fatal("a failed import must never enter the queue")
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func TestBackgroundSubmitValidatesParams(t *testing.T) {
|
|
191
|
+
e := newBgEnv(t)
|
|
192
|
+
for _, p := range []string{
|
|
193
|
+
`{}`, `{"key":"k","folder":"/share","relative":"a","extra":1}`, `{"key":"bad key","folder":"/share","relative":"a"}`,
|
|
194
|
+
`{"key":"k","folder":"/share","relative":""}`,
|
|
195
|
+
} {
|
|
196
|
+
if code, _ := e.call(alice, "bg.jobs.submit", p); code != "bad_request" {
|
|
197
|
+
t.Errorf("%s: %q", p, code)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func TestBackgroundQueueFullAndQuotaErrorsHaveStableCodes(t *testing.T) {
|
|
203
|
+
e := newBgEnv(t)
|
|
204
|
+
for i := 0; i < background.MaxQueued; i++ {
|
|
205
|
+
lookupSubmitterUID = func(string) string { return fmt.Sprint(1024 + i/2) }
|
|
206
|
+
key := "k" + string(rune('a'+i))
|
|
207
|
+
if code, _ := e.call(alice, "bg.jobs.submit", `{"key":"`+key+`","folder":"/share","relative":"a.txt"}`); code != "" {
|
|
208
|
+
t.Fatalf("%d: %q", i, code)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
lookupSubmitterUID = func(string) string { return "9999" }
|
|
212
|
+
if code, _ := e.call(alice, "bg.jobs.submit", `{"key":"more","folder":"/share","relative":"a.txt"}`); code != "queue-full" {
|
|
213
|
+
t.Fatalf("%q", code)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func TestBackgroundListGetCancelAndResult(t *testing.T) {
|
|
218
|
+
e := newBgEnv(t)
|
|
219
|
+
_, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
|
|
220
|
+
id := data(out)["job"].(map[string]any)["id"].(string)
|
|
221
|
+
idp := `{"id":"` + id + `"}`
|
|
222
|
+
|
|
223
|
+
if _, out = e.call(alice, "bg.jobs.list", `{}`); len(data(out)["jobs"].([]any)) != 1 {
|
|
224
|
+
t.Fatalf("%v", out)
|
|
225
|
+
}
|
|
226
|
+
if code, out := e.call(alice, "bg.jobs.get", idp); code != "" || data(out)["job"].(map[string]any)["id"] != id {
|
|
227
|
+
t.Fatalf("%s %v", code, out)
|
|
228
|
+
}
|
|
229
|
+
if code, _ := e.call(alice, "bg.jobs.result", idp); code != "not_ready" {
|
|
230
|
+
t.Fatalf("result of a queued job: %q", code)
|
|
231
|
+
}
|
|
232
|
+
ctx := context.Background()
|
|
233
|
+
if _, err := e.store.Claim(ctx); err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
sum := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
237
|
+
if err := e.store.Finish(ctx, id, background.Outcome{State: background.StateSucceeded, Result: &background.Result{SHA256: sum, Bytes: 5}}); err != nil {
|
|
238
|
+
t.Fatal(err)
|
|
239
|
+
}
|
|
240
|
+
code, out := e.call(alice, "bg.jobs.result", idp)
|
|
241
|
+
res := data(out)["result"].(map[string]any)
|
|
242
|
+
if code != "" || res["sha256"] != sum || res["bytes"].(float64) != 5 || res["worker"] != "sha256-v1" || res["jobId"] != id {
|
|
243
|
+
t.Fatalf("%s %v", code, out)
|
|
244
|
+
}
|
|
245
|
+
if code, _ := e.call(alice, "bg.jobs.cancel", idp); code != "conflict" {
|
|
246
|
+
t.Fatalf("cancel after completion: %q", code)
|
|
247
|
+
}
|
|
248
|
+
for _, m := range []string{"bg.jobs.get", "bg.jobs.cancel", "bg.jobs.result"} {
|
|
249
|
+
if code, _ := e.call(alice, m, `{"id":"`+strings.Repeat("0", 32)+`"}`); code != "not_found" {
|
|
250
|
+
t.Fatalf("%s unknown id: %q", m, code)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func TestBackgroundCancelQueuedJob(t *testing.T) {
|
|
256
|
+
e := newBgEnv(t)
|
|
257
|
+
_, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
|
|
258
|
+
id := data(out)["job"].(map[string]any)["id"].(string)
|
|
259
|
+
code, out := e.call(alice, "bg.jobs.cancel", `{"id":"`+id+`"}`)
|
|
260
|
+
if code != "" || data(out)["job"].(map[string]any)["state"] != "canceled" {
|
|
261
|
+
t.Fatalf("%s %v", code, out)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func TestBackgroundWorkerAliveExpires(t *testing.T) {
|
|
266
|
+
if workerAlive(background.WorkerInfo{At: time.Now().Add(-time.Minute)}, time.Now()) || !workerAlive(background.WorkerInfo{At: time.Now()}, time.Now()) {
|
|
267
|
+
t.Fatal("a stale heartbeat is not alive")
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func TestBackgroundBrowseListsThroughTheCallersSession(t *testing.T) {
|
|
272
|
+
e := newBgEnv(t)
|
|
273
|
+
e.files.page = filestation.Page{Files: []filestation.Entry{{Name: "a.txt", Path: "/share/a.txt"}}, Total: 1}
|
|
274
|
+
code, out := e.call(alice, "bg.browse", `{"folder":"/share","offset":100}`)
|
|
275
|
+
if code != "" {
|
|
276
|
+
t.Fatalf("%s %v", code, out)
|
|
277
|
+
}
|
|
278
|
+
if e.files.folder != "/share" || e.files.offset != 100 {
|
|
279
|
+
t.Fatalf("browsed %q at %d", e.files.folder, e.files.offset)
|
|
280
|
+
}
|
|
281
|
+
files := data(out)["files"].([]any)
|
|
282
|
+
if len(files) != 1 || files[0].(map[string]any)["path"] != "/share/a.txt" {
|
|
283
|
+
t.Fatalf("page %v", out)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
func TestBackgroundBrowseWithoutFolderListsTheShares(t *testing.T) {
|
|
288
|
+
e := newBgEnv(t)
|
|
289
|
+
if code, out := e.call(alice, "bg.browse", `{}`); code != "" {
|
|
290
|
+
t.Fatalf("%s %v", code, out)
|
|
291
|
+
}
|
|
292
|
+
if e.files.folder != "" || e.files.offset != 0 {
|
|
293
|
+
t.Fatalf("browsed %q at %d", e.files.folder, e.files.offset)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func TestBackgroundBrowseValidatesParamsAndPassesRefusalsThrough(t *testing.T) {
|
|
298
|
+
e := newBgEnv(t)
|
|
299
|
+
for _, params := range []string{`{"offset":-1}`, `{"folder":"/share","extra":1}`, `{"offset":"1"}`} {
|
|
300
|
+
if code, _ := e.call(alice, "bg.browse", params); code != "bad_request" {
|
|
301
|
+
t.Errorf("%s: %q", params, code)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if e.files.calls != 0 {
|
|
305
|
+
t.Fatal("invalid params must not reach the NAS")
|
|
306
|
+
}
|
|
307
|
+
e.files.err = &api.Error{Code: "denied", Message: "no"}
|
|
308
|
+
if code, _ := e.call(alice, "bg.browse", `{"folder":"/share"}`); code != "denied" {
|
|
309
|
+
t.Fatalf("got %q", code)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
func TestBackgroundListWithoutJobsIsAnEmptyArray(t *testing.T) {
|
|
314
|
+
e := newBgEnv(t)
|
|
315
|
+
_, out := e.call(alice, "bg.jobs.list", `{}`)
|
|
316
|
+
jobs, ok := data(out)["jobs"].([]any)
|
|
317
|
+
if !ok || len(jobs) != 0 {
|
|
318
|
+
t.Fatalf("jobs must be [] not null: %v", out)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"errors"
|
|
6
|
+
"regexp"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
type DelegateSettings struct {
|
|
10
|
+
Scope string
|
|
11
|
+
Operations, Destinations []string
|
|
12
|
+
Lifetime string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
var destinationKey = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
|
|
16
|
+
|
|
17
|
+
// ParseDelegateConfig consumes only build/startup settings. Legacy declarations
|
|
18
|
+
// retain their TTL/job branch; absent extension fields never imply permanence.
|
|
19
|
+
func ParseDelegateConfig(cfg Config) (DelegateSettings, error) {
|
|
20
|
+
out := DelegateSettings{Scope: cfg.DelegateScope, Lifetime: cfg.DelegateLifetime}
|
|
21
|
+
invalid := errors.New("invalid delegate build configuration")
|
|
22
|
+
if cfg.DelegateOperationsJSON != "" && json.Unmarshal([]byte(cfg.DelegateOperationsJSON), &out.Operations) != nil {
|
|
23
|
+
return DelegateSettings{}, invalid
|
|
24
|
+
}
|
|
25
|
+
if cfg.DelegateDestinationsJSON != "" && json.Unmarshal([]byte(cfg.DelegateDestinationsJSON), &out.Destinations) != nil {
|
|
26
|
+
return DelegateSettings{}, invalid
|
|
27
|
+
}
|
|
28
|
+
if out.Lifetime == "" {
|
|
29
|
+
if len(out.Destinations) != 0 {
|
|
30
|
+
return DelegateSettings{}, invalid
|
|
31
|
+
}
|
|
32
|
+
seen := map[string]bool{}
|
|
33
|
+
for _, op := range out.Operations {
|
|
34
|
+
switch op {
|
|
35
|
+
case "files.list", "files.info", "files.createFolder", "files.checkPermission", "files.write":
|
|
36
|
+
default:
|
|
37
|
+
return DelegateSettings{}, invalid
|
|
38
|
+
}
|
|
39
|
+
if seen[op] {
|
|
40
|
+
return DelegateSettings{}, invalid
|
|
41
|
+
}
|
|
42
|
+
seen[op] = true
|
|
43
|
+
}
|
|
44
|
+
return out, nil
|
|
45
|
+
}
|
|
46
|
+
if out.Lifetime != "until-revoked" || out.Scope == "" || len(out.Operations) != 1 || out.Operations[0] != "files.moveFromStaging" || len(out.Destinations) == 0 || len(out.Destinations) > 64 {
|
|
47
|
+
return DelegateSettings{}, invalid
|
|
48
|
+
}
|
|
49
|
+
seen := map[string]bool{}
|
|
50
|
+
for _, key := range out.Destinations {
|
|
51
|
+
if !destinationKey.MatchString(key) || seen[key] {
|
|
52
|
+
return DelegateSettings{}, invalid
|
|
53
|
+
}
|
|
54
|
+
seen[key] = true
|
|
55
|
+
}
|
|
56
|
+
return out, nil
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestDelegateBuildConfigStrictlySeparatesLegacyAndPermanent(t *testing.T) {
|
|
6
|
+
valid := Config{DelegateScope: "home", DelegateOperationsJSON: `["files.moveFromStaging"]`, DelegateDestinationsJSON: `["save"]`, DelegateLifetime: "until-revoked"}
|
|
7
|
+
settings, err := ParseDelegateConfig(valid)
|
|
8
|
+
if err != nil || settings.Lifetime != "until-revoked" || len(settings.Destinations) != 1 {
|
|
9
|
+
t.Fatal(settings, err)
|
|
10
|
+
}
|
|
11
|
+
for _, cfg := range []Config{{}, {DelegateScope: "home"}, {DelegateScope: "home", DelegateOperationsJSON: `["files.write"]`}} {
|
|
12
|
+
settings, err = ParseDelegateConfig(cfg)
|
|
13
|
+
if err != nil || settings.Lifetime != "" || len(settings.Destinations) != 0 {
|
|
14
|
+
t.Fatal("legacy changed", settings, err)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for _, mutate := range []func(*Config){
|
|
18
|
+
func(c *Config) { c.DelegateLifetime = "" }, func(c *Config) { c.DelegateScope = "" }, func(c *Config) { c.DelegateOperationsJSON = `["files.write"]` },
|
|
19
|
+
func(c *Config) { c.DelegateDestinationsJSON = `["save","save"]` }, func(c *Config) { c.DelegateDestinationsJSON = `["Save"]` }, func(c *Config) { c.DelegateDestinationsJSON = `[]` }, func(c *Config) { c.DelegateDestinationsJSON = `null` },
|
|
20
|
+
func(c *Config) { c.DelegateDestinationsJSON = `["save"] {}` },
|
|
21
|
+
} {
|
|
22
|
+
cfg := valid
|
|
23
|
+
mutate(&cfg)
|
|
24
|
+
if _, err = ParseDelegateConfig(cfg); err == nil {
|
|
25
|
+
t.Fatal("invalid trusted config accepted", cfg)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"path"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/synopackageland/syno-pkg-land-sdk/identity"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// StandaloneIdentity registers identity.status/session/login/logout through
|
|
12
|
+
// Service.Wrap. Business routes keep their permissions.json policies. Call only
|
|
13
|
+
// for capabilities.identity=standalone; the path comes from the CGI environment.
|
|
14
|
+
func StandaloneIdentity(cfg Config, scriptName string) (*identity.Service, error) {
|
|
15
|
+
if !strings.HasPrefix(scriptName, "/") || strings.HasPrefix(scriptName, "//") || path.Clean(scriptName) != scriptName || !strings.HasSuffix(scriptName, "/cgi/api.cgi") {
|
|
16
|
+
return nil, errors.New("identity requires an absolute CGI script path")
|
|
17
|
+
}
|
|
18
|
+
cookiePath := strings.TrimSuffix(path.Dir(path.Dir(scriptName)), "/") + "/"
|
|
19
|
+
return identity.New(identity.Config{DataDir: cfg.DataDir, CookiePath: cookiePath})
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestIdentityApplicationPath(t *testing.T) {
|
|
6
|
+
for _, tc := range []struct {
|
|
7
|
+
script string
|
|
8
|
+
valid bool
|
|
9
|
+
}{{"/webman/3rdparty/test/cgi/api.cgi", true}, {"/cgi/api.cgi", true}, {"", false}, {"cgi/api.cgi", false}, {"//evil/cgi/api.cgi", false}} {
|
|
10
|
+
_, err := StandaloneIdentity(Config{DataDir: t.TempDir()}, tc.script)
|
|
11
|
+
if (err == nil) != tc.valid {
|
|
12
|
+
t.Errorf("script %q: %v", tc.script, err)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
|
|
6
|
+
"github.com/synopackageland/syno-pkg-land-sdk/datamigrate"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Migrations returns this app's schema migrations. The template ships one
|
|
10
|
+
// empty example; replace it with your own steps. Versions start at 1 and never
|
|
11
|
+
// change meaning. Every step must be idempotent: a crash between Apply and the
|
|
12
|
+
// recorded version, or two CGI requests racing on the first run after an
|
|
13
|
+
// upgrade, can run one step twice. A failed step stops the run with the
|
|
14
|
+
// previous version still recorded, and the next run resumes from there; the
|
|
15
|
+
// utility itself never deletes or rewrites data.
|
|
16
|
+
func Migrations() []datamigrate.Migration {
|
|
17
|
+
return []datamigrate.Migration{
|
|
18
|
+
{
|
|
19
|
+
Version: 1,
|
|
20
|
+
Name: "example",
|
|
21
|
+
Apply: func(datamigrate.Context) error {
|
|
22
|
+
// Nothing to do yet. Read ctx.DataDir, rewrite files with a
|
|
23
|
+
// temporary file + rename, and keep the step idempotent.
|
|
24
|
+
return nil
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Migrate applies Migrations to the package's persistent var directory. The
|
|
31
|
+
// CGI calls it once per request; the recorded data-version makes that a cheap
|
|
32
|
+
// no-op. SYNOPKG_OLD_PKGVER is set when DSM starts the app from a lifecycle
|
|
33
|
+
// script; in a plain CGI request the version of the last successful run is
|
|
34
|
+
// used instead.
|
|
35
|
+
func Migrate(cfg Config) error {
|
|
36
|
+
_, err := datamigrate.Run(cfg.DataDir, datamigrate.Info{
|
|
37
|
+
OldPkgVer: os.Getenv("SYNOPKG_OLD_PKGVER"),
|
|
38
|
+
NewPkgVer: cfg.Version,
|
|
39
|
+
}, Migrations())
|
|
40
|
+
return err
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/synopackageland/syno-pkg-land-sdk/datamigrate"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestMigrationsAreOrderedUniqueAndValid(t *testing.T) {
|
|
10
|
+
migrations := Migrations()
|
|
11
|
+
if len(migrations) == 0 {
|
|
12
|
+
t.Fatal("the template must ship one example migration")
|
|
13
|
+
}
|
|
14
|
+
for i, migration := range migrations {
|
|
15
|
+
if migration.Version < 1 || migration.Apply == nil || migration.Name == "" {
|
|
16
|
+
t.Fatalf("migration %d is invalid: %+v", i, migration)
|
|
17
|
+
}
|
|
18
|
+
if i > 0 && migrations[i-1].Version >= migration.Version {
|
|
19
|
+
t.Fatalf("migrations are not strictly version-ascending: %+v", migrations)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestMigrateRecordsTheSchemaAndPackageVersion(t *testing.T) {
|
|
25
|
+
dir := t.TempDir()
|
|
26
|
+
t.Setenv("SYNOPKG_OLD_PKGVER", "0.1.0-0001")
|
|
27
|
+
cfg := Config{DataDir: dir, Version: "0.2.0-0002"}
|
|
28
|
+
if err := Migrate(cfg); err != nil {
|
|
29
|
+
t.Fatalf("Migrate: %v", err)
|
|
30
|
+
}
|
|
31
|
+
state, err := datamigrate.Load(dir)
|
|
32
|
+
if err != nil {
|
|
33
|
+
t.Fatalf("Load: %v", err)
|
|
34
|
+
}
|
|
35
|
+
if state.Schema != 1 || state.PackageVersion != "0.2.0-0002" {
|
|
36
|
+
t.Fatalf("recorded state = %+v", state)
|
|
37
|
+
}
|
|
38
|
+
if err := Migrate(cfg); err != nil {
|
|
39
|
+
t.Fatalf("second Migrate must be a no-op: %v", err)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
|
|
7
|
+
"github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
8
|
+
"github.com/synopackageland/syno-pkg-land-sdk/portal"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// portalRoutes serves portal.status when dsm-package.yaml declares `portal`.
|
|
12
|
+
// An administrator sets the alias with DSM's own AppPortal API from the UI;
|
|
13
|
+
// the package only reads DSM's saved setting.
|
|
14
|
+
func (cfg Config) portalRoutes() []api.Route {
|
|
15
|
+
if !cfg.Portal {
|
|
16
|
+
return nil
|
|
17
|
+
}
|
|
18
|
+
return []api.Route{api.Handle("portal.status", cfg.portalStatus)}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (cfg Config) portalStatus(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
|
|
22
|
+
if err := decodeParams(params, &struct{}{}); err != nil {
|
|
23
|
+
return nil, err
|
|
24
|
+
}
|
|
25
|
+
path := cfg.PortalConfig
|
|
26
|
+
if path == "" {
|
|
27
|
+
path = portal.ConfigPath(cfg.PackageID)
|
|
28
|
+
}
|
|
29
|
+
return portal.Read(path, "SYNO.SDS."+cfg.PackageID+".Instance"), nil
|
|
30
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func portalConfig(t *testing.T, saved string) Config {
|
|
10
|
+
cfg := testConfig(t)
|
|
11
|
+
cfg.Portal = true
|
|
12
|
+
cfg.PortalConfig = filepath.Join(t.TempDir(), "AppPortal.json")
|
|
13
|
+
if saved != "" {
|
|
14
|
+
if err := os.WriteFile(cfg.PortalConfig, []byte(saved), 0o644); err != nil {
|
|
15
|
+
t.Fatal(err)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return cfg
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func TestPortalStatusTellsEverySignedInUserTheAlias(t *testing.T) {
|
|
22
|
+
cfg := portalConfig(t, `{"alias":"hello-go","id":"SYNO.SDS.HelloGo.Instance","version":3}`)
|
|
23
|
+
code, out := call(t, cfg, bob, "portal.status", `{}`)
|
|
24
|
+
if code != "" {
|
|
25
|
+
t.Fatalf("%q %v", code, out)
|
|
26
|
+
}
|
|
27
|
+
data := out["data"].(map[string]any)
|
|
28
|
+
if data["state"] != "active" || data["alias"] != "hello-go" {
|
|
29
|
+
t.Fatalf("%v", data)
|
|
30
|
+
}
|
|
31
|
+
if code, _ := call(t, cfg, nobody, "portal.status", `{}`); code != "session_required" {
|
|
32
|
+
t.Fatalf("anonymous: %q", code)
|
|
33
|
+
}
|
|
34
|
+
if code, _ := call(t, cfg, bob, "portal.status", `{"alias":"x"}`); code != "bad_request" {
|
|
35
|
+
t.Fatalf("params: %q", code)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestPortalStatusFailsClosedOnAnotherAppsSetting(t *testing.T) {
|
|
40
|
+
cfg := portalConfig(t, `{"alias":"hello-go","id":"SYNO.SDS.Other.Instance"}`)
|
|
41
|
+
_, out := call(t, cfg, bob, "portal.status", `{}`)
|
|
42
|
+
if data := out["data"].(map[string]any); data["state"] != "unavailable" || data["alias"] != nil {
|
|
43
|
+
t.Fatalf("%v", data)
|
|
44
|
+
}
|
|
45
|
+
_, out = call(t, portalConfig(t, ""), bob, "portal.status", `{}`)
|
|
46
|
+
if data := out["data"].(map[string]any); data["state"] != "inactive" {
|
|
47
|
+
t.Fatalf("%v", data)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestPortalStatusExistsOnlyForAPortalBuild(t *testing.T) {
|
|
52
|
+
if code, _ := call(t, testConfig(t), alice, "portal.status", `{}`); code != "unknown_method" {
|
|
53
|
+
t.Fatalf("%q", code)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import "github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
4
|
+
|
|
5
|
+
// rootRoutes is empty: this package declares privilege: none and never runs
|
|
6
|
+
// anything as root. `synopkgland init -t native-go --privilege root` creates
|
|
7
|
+
// root.go instead, which reads the results of administrator-approved root
|
|
8
|
+
// operations.
|
|
9
|
+
func (Config) rootRoutes() []api.Route { return nil }
|